Skip to content

Commit ceb072c

Browse files
authored
Merge branch 'main' into saumya/stress-tests-gh
2 parents cacebae + 865a379 commit ceb072c

11 files changed

Lines changed: 730 additions & 62 deletions

.github/workflows/issue-acknowledge.yml

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,11 @@ jobs:
1111
acknowledge:
1212
runs-on: ubuntu-latest
1313
permissions:
14-
issues: read
14+
issues: write
1515
steps:
1616
# Step 1: Wait 15 minutes
1717
- name: Wait 15 minutes
18-
run: sleep 10
18+
run: sleep 900
1919

2020
# Step 2: Check if a maintainer already responded
2121
- name: Check for existing maintainer response
@@ -24,7 +24,7 @@ jobs:
2424
with:
2525
github-token: ${{ secrets.GITHUB_TOKEN }}
2626
script: |
27-
const maintainers = ['sumitmsft','dlevy-msft-sql'];
27+
const maintainers = ['sumitmsft','dlevy-msft-sql','gargsaumya','bewithgaurav','subrata-ms','jahnvi480','saurabh500'];
2828
2929
const comments = await github.rest.issues.listComments({
3030
owner: context.repo.owner,
@@ -44,11 +44,25 @@ jobs:
4444
if: steps.check.outputs.skip == 'false'
4545
uses: actions/github-script@v7
4646
with:
47-
github-token: ${{ secrets.SUMIT_PAT_FOR_AUTO_RESPONSE }}
47+
github-token: ${{ secrets.GITHUB_TOKEN }}
4848
script: |
4949
await github.rest.issues.createComment({
5050
owner: context.repo.owner,
5151
repo: context.repo.repo,
5252
issue_number: context.payload.issue.number,
53-
body: `Hi @${context.payload.issue.user.login}, thank you for opening this issue!\n\nOur team will review it shortly. We aim to triage all new issues within 24-48 hours and get back to you.\n\nThank you for your patience!`
53+
body: `Hi @${context.payload.issue.user.login}, thank you for opening this issue!\n\nOur team will review it shortly. We aim to triage all new issues within 24-48 hours and get back to you.\n\nIf you have additional information to share, please feel free to update the issue.\n\nThank you for your patience!`
54+
});
55+
56+
# Step 4: Add "triage needed" label if no maintainer has responded
57+
- name: Add triage needed label
58+
if: steps.check.outputs.skip == 'false'
59+
uses: actions/github-script@v7
60+
with:
61+
github-token: ${{ secrets.GITHUB_TOKEN }}
62+
script: |
63+
await github.rest.issues.addLabels({
64+
owner: context.repo.owner,
65+
repo: context.repo.repo,
66+
issue_number: context.payload.issue.number,
67+
labels: ['triage needed']
5468
});

.github/workflows/pr-code-coverage.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -426,8 +426,8 @@ jobs:
426426
--arg covered_lines "${{ env.COVERED_LINES }}" \
427427
--arg total_lines "${{ env.TOTAL_LINES }}" \
428428
--arg patch_coverage_pct "${{ env.PATCH_COVERAGE_PCT }}" \
429-
--arg low_coverage_files "${{ env.LOW_COVERAGE_FILES }}" \
430-
--arg patch_coverage_summary "${{ env.PATCH_COVERAGE_SUMMARY }}" \
429+
--arg low_coverage_files "$LOW_COVERAGE_FILES" \
430+
--arg patch_coverage_summary "$PATCH_COVERAGE_SUMMARY" \
431431
--arg ado_url "${{ env.ADO_URL }}" \
432432
'{
433433
pr_number: $pr_number,

mssql_python/connection_string_parser.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -108,8 +108,10 @@ def _normalize_params(params: Dict[str, str], warn_rejected: bool = True) -> Dic
108108
if normalized_key in _RESERVED_PARAMETERS:
109109
continue
110110

111-
# Parameter is allowed
112-
filtered[normalized_key] = value
111+
# First-wins: match ODBC behaviour where the first
112+
# occurrence of a synonym group takes precedence.
113+
if normalized_key not in filtered:
114+
filtered[normalized_key] = value
113115
else:
114116
# Parameter is not in allow-list
115117
# Note: In normal flow, this should be empty since parser validates first

mssql_python/constants.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,8 @@ class ConstantsDDBC(Enum):
115115
SQL_FETCH_RELATIVE = 6
116116
SQL_FETCH_BOOKMARK = 8
117117
SQL_DATETIMEOFFSET = -155
118+
SQL_SS_TIME2 = -154
119+
SQL_SS_XML = -152
118120
SQL_C_SS_TIMESTAMPOFFSET = 0x4001
119121
SQL_SCOPE_CURROW = 0
120122
SQL_BEST_ROWID = 1
@@ -365,6 +367,12 @@ def get_valid_types(cls) -> set:
365367
ConstantsDDBC.SQL_DATE.value,
366368
ConstantsDDBC.SQL_TIME.value,
367369
ConstantsDDBC.SQL_TIMESTAMP.value,
370+
ConstantsDDBC.SQL_TYPE_DATE.value,
371+
ConstantsDDBC.SQL_TYPE_TIME.value,
372+
ConstantsDDBC.SQL_TYPE_TIMESTAMP.value,
373+
ConstantsDDBC.SQL_SS_TIME2.value,
374+
ConstantsDDBC.SQL_DATETIMEOFFSET.value,
375+
ConstantsDDBC.SQL_SS_XML.value,
368376
ConstantsDDBC.SQL_GUID.value,
369377
}
370378

mssql_python/cursor.py

Lines changed: 66 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
import warnings
1818
from typing import List, Union, Any, Optional, Tuple, Sequence, TYPE_CHECKING, Iterable
1919
from mssql_python.constants import ConstantsDDBC as ddbc_sql_const, SQLTypes
20-
from mssql_python.helpers import check_error
20+
from mssql_python.helpers import check_error, connstr_to_pycore_params
2121
from mssql_python.logging import logger
2222
from mssql_python import ddbc_bindings
2323
from mssql_python.exceptions import (
@@ -844,7 +844,7 @@ def _reset_inputsizes(self) -> None:
844844
self._inputsizes = None
845845

846846
def _get_c_type_for_sql_type(self, sql_type: int) -> int:
847-
"""Map SQL type to appropriate C type for parameter binding"""
847+
"""Map SQL type to appropriate C type for parameter binding."""
848848
sql_to_c_type = {
849849
ddbc_sql_const.SQL_CHAR.value: ddbc_sql_const.SQL_C_CHAR.value,
850850
ddbc_sql_const.SQL_VARCHAR.value: ddbc_sql_const.SQL_C_CHAR.value,
@@ -865,9 +865,19 @@ def _get_c_type_for_sql_type(self, sql_type: int) -> int:
865865
ddbc_sql_const.SQL_BINARY.value: ddbc_sql_const.SQL_C_BINARY.value,
866866
ddbc_sql_const.SQL_VARBINARY.value: ddbc_sql_const.SQL_C_BINARY.value,
867867
ddbc_sql_const.SQL_LONGVARBINARY.value: ddbc_sql_const.SQL_C_BINARY.value,
868+
# ODBC 3.x date/time types (reported by ODBC 18 driver)
869+
ddbc_sql_const.SQL_TYPE_DATE.value: ddbc_sql_const.SQL_C_TYPE_DATE.value,
870+
ddbc_sql_const.SQL_TYPE_TIME.value: ddbc_sql_const.SQL_C_TYPE_TIME.value,
871+
ddbc_sql_const.SQL_TYPE_TIMESTAMP.value: ddbc_sql_const.SQL_C_TYPE_TIMESTAMP.value,
872+
ddbc_sql_const.SQL_SS_TIME2.value: ddbc_sql_const.SQL_C_TYPE_TIME.value,
873+
ddbc_sql_const.SQL_DATETIMEOFFSET.value: ddbc_sql_const.SQL_C_SS_TIMESTAMPOFFSET.value,
874+
# ODBC 2.x aliases (accepted by setinputsizes via SQLTypes)
868875
ddbc_sql_const.SQL_DATE.value: ddbc_sql_const.SQL_C_TYPE_DATE.value,
869876
ddbc_sql_const.SQL_TIME.value: ddbc_sql_const.SQL_C_TYPE_TIME.value,
870877
ddbc_sql_const.SQL_TIMESTAMP.value: ddbc_sql_const.SQL_C_TYPE_TIMESTAMP.value,
878+
# Other types
879+
ddbc_sql_const.SQL_GUID.value: ddbc_sql_const.SQL_C_GUID.value,
880+
ddbc_sql_const.SQL_SS_XML.value: ddbc_sql_const.SQL_C_WCHAR.value,
871881
}
872882
return sql_to_c_type.get(sql_type, ddbc_sql_const.SQL_C_DEFAULT.value)
873883

@@ -1026,34 +1036,71 @@ def _map_data_type(self, sql_type):
10261036
"""
10271037
Map SQL data type to Python data type.
10281038
1039+
Maps the ODBC SQL type code returned by SQLDescribeCol to the
1040+
corresponding Python type for cursor.description[i][1].
1041+
1042+
The ODBC 18 driver for SQL Server reports these type codes:
1043+
Standard ODBC 3.x types:
1044+
SQL_CHAR(1), SQL_VARCHAR(12), SQL_LONGVARCHAR(-1),
1045+
SQL_WCHAR(-8), SQL_WVARCHAR(-9), SQL_WLONGVARCHAR(-10),
1046+
SQL_INTEGER(4), SQL_SMALLINT(5), SQL_TINYINT(-6), SQL_BIGINT(-5),
1047+
SQL_BIT(-7), SQL_FLOAT(6), SQL_REAL(7), SQL_DOUBLE(8),
1048+
SQL_DECIMAL(3), SQL_NUMERIC(2),
1049+
SQL_BINARY(-2), SQL_VARBINARY(-3), SQL_LONGVARBINARY(-4),
1050+
SQL_TYPE_DATE(91), SQL_TYPE_TIME(92), SQL_TYPE_TIMESTAMP(93), SQL_GUID(-11)
1051+
SQL Server-specific types (from msodbcsql.h):
1052+
SQL_SS_TIME2(-154) for time columns
1053+
SQL_DATETIMEOFFSET(-155) for datetimeoffset columns
1054+
SQL_SS_XML(-152) for xml columns
1055+
1056+
ODBC 2.x aliases (9, 10, 11) are also accepted defensively.
1057+
10291058
Args:
1030-
sql_type: SQL data type.
1059+
sql_type: SQL data type code from SQLDescribeCol.
10311060
10321061
Returns:
10331062
Corresponding Python data type.
10341063
"""
10351064
sql_to_python_type = {
1036-
ddbc_sql_const.SQL_INTEGER.value: int,
1037-
ddbc_sql_const.SQL_VARCHAR.value: str,
1038-
ddbc_sql_const.SQL_WVARCHAR.value: str,
1065+
# String types
10391066
ddbc_sql_const.SQL_CHAR.value: str,
1067+
ddbc_sql_const.SQL_VARCHAR.value: str,
1068+
ddbc_sql_const.SQL_LONGVARCHAR.value: str,
10401069
ddbc_sql_const.SQL_WCHAR.value: str,
1070+
ddbc_sql_const.SQL_WVARCHAR.value: str,
1071+
ddbc_sql_const.SQL_WLONGVARCHAR.value: str,
1072+
# Integer types
1073+
ddbc_sql_const.SQL_INTEGER.value: int,
1074+
ddbc_sql_const.SQL_SMALLINT.value: int,
1075+
ddbc_sql_const.SQL_TINYINT.value: int,
1076+
ddbc_sql_const.SQL_BIGINT.value: int,
1077+
# Floating-point types
10411078
ddbc_sql_const.SQL_FLOAT.value: float,
10421079
ddbc_sql_const.SQL_DOUBLE.value: float,
1080+
ddbc_sql_const.SQL_REAL.value: float,
1081+
# Exact numeric types
10431082
ddbc_sql_const.SQL_DECIMAL.value: decimal.Decimal,
10441083
ddbc_sql_const.SQL_NUMERIC.value: decimal.Decimal,
1045-
ddbc_sql_const.SQL_DATE.value: datetime.date,
1046-
ddbc_sql_const.SQL_TIMESTAMP.value: datetime.datetime,
1047-
ddbc_sql_const.SQL_TIME.value: datetime.time,
1084+
# Date/time types — values the ODBC 18 driver actually reports
1085+
ddbc_sql_const.SQL_TYPE_DATE.value: datetime.date, # 91 — date
1086+
ddbc_sql_const.SQL_TYPE_TIME.value: datetime.time, # 92 — time (ODBC 3.x)
1087+
ddbc_sql_const.SQL_TYPE_TIMESTAMP.value: datetime.datetime, # 93 — datetime/datetime2/smalldatetime
1088+
ddbc_sql_const.SQL_SS_TIME2.value: datetime.time, # -154 — time
1089+
ddbc_sql_const.SQL_DATETIMEOFFSET.value: datetime.datetime, # -155 — datetimeoffset
1090+
# ODBC 2.x date/time aliases (defensive, in case any driver reports these)
1091+
ddbc_sql_const.SQL_DATE.value: datetime.date, # 9
1092+
ddbc_sql_const.SQL_TIME.value: datetime.time, # 10
1093+
ddbc_sql_const.SQL_TIMESTAMP.value: datetime.datetime, # 11
1094+
# Boolean
10481095
ddbc_sql_const.SQL_BIT.value: bool,
1049-
ddbc_sql_const.SQL_TINYINT.value: int,
1050-
ddbc_sql_const.SQL_SMALLINT.value: int,
1051-
ddbc_sql_const.SQL_BIGINT.value: int,
1096+
# Binary types
10521097
ddbc_sql_const.SQL_BINARY.value: bytes,
10531098
ddbc_sql_const.SQL_VARBINARY.value: bytes,
10541099
ddbc_sql_const.SQL_LONGVARBINARY.value: bytes,
1100+
# UUID
10551101
ddbc_sql_const.SQL_GUID.value: uuid.UUID,
1056-
# Add more mappings as needed
1102+
# XML — driver reports SQL_SS_XML (-152), fetched as str
1103+
ddbc_sql_const.SQL_SS_XML.value: str,
10571104
}
10581105
return sql_to_python_type.get(sql_type, str)
10591106

@@ -2451,6 +2498,7 @@ def nextset(self) -> Union[bool, None]:
24512498
)
24522499
return True
24532500

2501+
# ── Mapping from ODBC connection-string keywords (lowercase, as _parse returns)
24542502
def _bulkcopy(
24552503
self,
24562504
table_name: str,
@@ -2585,38 +2633,10 @@ def _bulkcopy(
25852633
"Specify the target database explicitly to avoid accidentally writing to system databases."
25862634
)
25872635

2588-
# Build connection context for bulk copy library
2589-
# Note: Password is extracted separately to avoid storing it in the main context
2590-
# dict that could be accidentally logged or exposed in error messages.
2591-
trust_cert = params.get("trustservercertificate", "yes").lower() in ("yes", "true")
2592-
2593-
# Parse encryption setting from connection string
2594-
encrypt_param = params.get("encrypt")
2595-
if encrypt_param is not None:
2596-
encrypt_value = encrypt_param.strip().lower()
2597-
if encrypt_value in ("yes", "true", "mandatory", "required"):
2598-
encryption = "Required"
2599-
elif encrypt_value in ("no", "false", "optional"):
2600-
encryption = "Optional"
2601-
else:
2602-
# Pass through unrecognized values (e.g., "Strict") to the underlying driver
2603-
encryption = encrypt_param
2604-
else:
2605-
encryption = "Optional"
2606-
2607-
context = {
2608-
"server": params.get("server"),
2609-
"database": params.get("database"),
2610-
"trust_server_certificate": trust_cert,
2611-
"encryption": encryption,
2612-
}
2613-
2614-
# Build pycore_context with appropriate authentication.
2615-
# For Azure AD: acquire a FRESH token right now instead of reusing
2616-
# the one from connect() time — avoids expired-token errors when
2617-
# bulkcopy() is called long after the original connection.
2618-
pycore_context = dict(context)
2636+
# Translate parsed connection string into the dict py-core expects.
2637+
pycore_context = connstr_to_pycore_params(params)
26192638

2639+
# Token acquisition — only thing cursor must handle (needs azure-identity SDK)
26202640
if self.connection._auth_type:
26212641
# Fresh token acquisition for mssql-py-core connection
26222642
from mssql_python.auth import AADAuth
@@ -2633,10 +2653,6 @@ def _bulkcopy(
26332653
"Bulk copy: acquired fresh Azure AD token for auth_type=%s",
26342654
self.connection._auth_type,
26352655
)
2636-
else:
2637-
# SQL Server authentication — use uid/password from connection string
2638-
pycore_context["user_name"] = params.get("uid", "")
2639-
pycore_context["password"] = params.get("pwd", "")
26402656

26412657
pycore_connection = None
26422658
pycore_cursor = None
@@ -2675,9 +2691,8 @@ def _bulkcopy(
26752691
finally:
26762692
# Clear sensitive data to minimize memory exposure
26772693
if pycore_context:
2678-
pycore_context.pop("password", None)
2679-
pycore_context.pop("user_name", None)
2680-
pycore_context.pop("access_token", None)
2694+
for key in ("password", "user_name", "access_token"):
2695+
pycore_context.pop(key, None)
26812696
# Clean up bulk copy resources
26822697
for resource in (pycore_cursor, pycore_connection):
26832698
if resource and hasattr(resource, "close"):

mssql_python/helpers.py

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -250,6 +250,97 @@ def _sanitize_for_logging(input_val: Any, max_length: int = max_log_length) -> s
250250
return True, None, sanitized_attr, sanitized_val
251251

252252

253+
def connstr_to_pycore_params(params: dict) -> dict:
254+
"""Translate parsed ODBC connection-string params for py-core's bulk copy path.
255+
256+
When ``cursor.bulkcopy()`` is called, mssql-python opens a *separate*
257+
connection through mssql-py-core.
258+
py-core's ``connection.rs`` expects a Python dict with snake_case keys —
259+
different from the ODBC-style keys that ``_ConnectionStringParser._parse``
260+
returns.
261+
262+
This function bridges that gap: it maps lowercase ODBC keys (e.g.
263+
``"trustservercertificate"``) to py-core keys (``"trust_server_certificate"``)
264+
and converts numeric strings to ``int`` for timeout/size params.
265+
Boolean params (TrustServerCertificate, MultiSubnetFailover) are passed as
266+
strings — ``connection.rs`` validates Yes/No and rejects invalid values.
267+
Unrecognised keys are silently dropped.
268+
"""
269+
# Only keys listed below are forwarded to py-core.
270+
# Unknown/reserved keys (app, workstationid, language, connect_timeout,
271+
# mars_connection) are silently dropped here. In the normal connect()
272+
# path the parser validates keywords first (validate_keywords=True),
273+
# but bulkcopy parses with validation off, so this mapping is the
274+
# authoritative filter in that path.
275+
key_map = {
276+
# auth / credentials
277+
"uid": "user_name",
278+
"pwd": "password",
279+
"trusted_connection": "trusted_connection",
280+
"authentication": "authentication",
281+
# server (accept parser synonyms)
282+
"server": "server",
283+
"addr": "server",
284+
"address": "server",
285+
# database
286+
"database": "database",
287+
"applicationintent": "application_intent",
288+
# encryption / TLS (include snake_case alias the parser may emit)
289+
"encrypt": "encryption",
290+
"trustservercertificate": "trust_server_certificate",
291+
"trust_server_certificate": "trust_server_certificate",
292+
"hostnameincertificate": "host_name_in_certificate",
293+
"servercertificate": "server_certificate",
294+
# Kerberos
295+
"serverspn": "server_spn",
296+
# network
297+
"multisubnetfailover": "multi_subnet_failover",
298+
"ipaddresspreference": "ip_address_preference",
299+
"keepalive": "keep_alive",
300+
"keepaliveinterval": "keep_alive_interval",
301+
# sizing / limits ("packet size" with space is a common pyodbc-ism)
302+
"packetsize": "packet_size",
303+
"packet size": "packet_size",
304+
"connectretrycount": "connect_retry_count",
305+
"connectretryinterval": "connect_retry_interval",
306+
}
307+
int_keys = {
308+
"packet_size",
309+
"connect_retry_count",
310+
"connect_retry_interval",
311+
"keep_alive",
312+
"keep_alive_interval",
313+
}
314+
315+
pycore_params: dict = {}
316+
317+
for connstr_key, pycore_key in key_map.items():
318+
raw_value = params.get(connstr_key)
319+
if raw_value is None:
320+
continue
321+
322+
# First-wins: match ODBC behaviour — first synonym in the
323+
# connection string takes precedence (e.g. Addr before Server).
324+
if pycore_key in pycore_params:
325+
continue
326+
327+
# ODBC values are always strings; py-core expects native types for int keys.
328+
# Boolean params (trust_server_certificate, multi_subnet_failover) are passed
329+
# as strings — all Yes/No validation is in connection.rs for single-location
330+
# consistency with Encrypt, ApplicationIntent, IPAddressPreference, etc.
331+
if pycore_key in int_keys:
332+
# Numeric params (timeouts, packet size, etc.) — skip on bad input
333+
try:
334+
pycore_params[pycore_key] = int(raw_value)
335+
except (ValueError, TypeError):
336+
pass # let py-core fall back to its compiled-in default
337+
else:
338+
# String params (server, database, encryption, etc.) — pass through
339+
pycore_params[pycore_key] = raw_value
340+
341+
return pycore_params
342+
343+
253344
# Settings functionality moved here to avoid circular imports
254345

255346
# Initialize the locale setting only once at module import time

0 commit comments

Comments
 (0)