mssql-python 1.16.0__cp315-cp315-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.
- mssql_python/__init__.py +662 -0
- mssql_python/async_query/__init__.py +40 -0
- mssql_python/async_query/_connection_context.py +39 -0
- mssql_python/async_query/_native.py +23 -0
- mssql_python/async_query/async_connection.py +160 -0
- mssql_python/async_query/async_cursor.py +230 -0
- mssql_python/async_query/async_execute.py +115 -0
- mssql_python/async_query/async_fetch.py +134 -0
- mssql_python/async_query/exception_translator.py +114 -0
- mssql_python/auth.py +913 -0
- mssql_python/connection.py +2357 -0
- mssql_python/connection_string_builder.py +114 -0
- mssql_python/connection_string_parser.py +424 -0
- mssql_python/constants.py +701 -0
- mssql_python/cursor.py +4188 -0
- mssql_python/db_connection.py +98 -0
- mssql_python/ddbc_bindings.cp315-amd64.pyd +0 -0
- mssql_python/ddbc_bindings.py +215 -0
- mssql_python/decimal_config.py +114 -0
- mssql_python/exceptions.py +588 -0
- mssql_python/helpers.py +416 -0
- mssql_python/logging.py +714 -0
- mssql_python/mssql_python.pyi +481 -0
- mssql_python/msvcp140.dll +0 -0
- mssql_python/odbc_provider.py +251 -0
- mssql_python/parameter_helper.py +488 -0
- mssql_python/perf_timer.py +286 -0
- mssql_python/pooling.py +151 -0
- mssql_python/py.typed +1 -0
- mssql_python/row.py +398 -0
- mssql_python/type.py +160 -0
- mssql_python-1.16.0.dist-info/METADATA +102 -0
- mssql_python-1.16.0.dist-info/RECORD +36 -0
- mssql_python-1.16.0.dist-info/WHEEL +5 -0
- mssql_python-1.16.0.dist-info/licenses/LICENSE +47 -0
- mssql_python-1.16.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Copyright (c) Microsoft Corporation.
|
|
3
|
+
Licensed under the MIT license.
|
|
4
|
+
|
|
5
|
+
Connection string builder for mssql-python.
|
|
6
|
+
|
|
7
|
+
Reconstructs ODBC connection strings from parameter dictionaries
|
|
8
|
+
with proper escaping and formatting per MS-ODBCSTR specification.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from typing import Dict, Optional
|
|
12
|
+
from mssql_python.constants import _CONNECTION_STRING_DRIVER_KEY
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class _ConnectionStringBuilder:
|
|
16
|
+
"""
|
|
17
|
+
Internal builder for ODBC connection strings. Not part of public API.
|
|
18
|
+
|
|
19
|
+
Handles proper escaping of special characters and reconstructs
|
|
20
|
+
connection strings in ODBC format.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
def __init__(self, initial_params: Optional[Dict[str, str]] = None):
|
|
24
|
+
"""
|
|
25
|
+
Initialize the builder with optional initial parameters.
|
|
26
|
+
|
|
27
|
+
Args:
|
|
28
|
+
initial_params: Dictionary of initial connection parameters
|
|
29
|
+
"""
|
|
30
|
+
self._params: Dict[str, str] = initial_params.copy() if initial_params else {}
|
|
31
|
+
|
|
32
|
+
def add_param(self, key: str, value: str) -> "_ConnectionStringBuilder":
|
|
33
|
+
"""
|
|
34
|
+
Add or update a connection parameter.
|
|
35
|
+
|
|
36
|
+
Args:
|
|
37
|
+
key: Parameter name (should be normalized canonical name)
|
|
38
|
+
value: Parameter value
|
|
39
|
+
|
|
40
|
+
Returns:
|
|
41
|
+
Self for method chaining
|
|
42
|
+
"""
|
|
43
|
+
self._params[key] = str(value)
|
|
44
|
+
return self
|
|
45
|
+
|
|
46
|
+
def build(self) -> str:
|
|
47
|
+
"""
|
|
48
|
+
Build the final connection string.
|
|
49
|
+
|
|
50
|
+
Returns:
|
|
51
|
+
ODBC-formatted connection string with proper escaping
|
|
52
|
+
|
|
53
|
+
Note:
|
|
54
|
+
- Driver parameter is placed first
|
|
55
|
+
- Other parameters are sorted for consistency
|
|
56
|
+
- Values are escaped if they contain special characters
|
|
57
|
+
"""
|
|
58
|
+
parts = []
|
|
59
|
+
|
|
60
|
+
# Build in specific order: Driver first, then others
|
|
61
|
+
if _CONNECTION_STRING_DRIVER_KEY in self._params:
|
|
62
|
+
parts.append(f"Driver={self._escape_value(self._params['Driver'])}")
|
|
63
|
+
|
|
64
|
+
# Add other parameters (sorted for consistency)
|
|
65
|
+
for key in sorted(self._params.keys()):
|
|
66
|
+
if key == "Driver":
|
|
67
|
+
continue # Already added
|
|
68
|
+
|
|
69
|
+
value = self._params[key]
|
|
70
|
+
escaped_value = self._escape_value(value)
|
|
71
|
+
parts.append(f"{key}={escaped_value}")
|
|
72
|
+
|
|
73
|
+
# Join with semicolons
|
|
74
|
+
return ";".join(parts)
|
|
75
|
+
|
|
76
|
+
def _escape_value(self, value: str) -> str:
|
|
77
|
+
"""
|
|
78
|
+
Escape a parameter value if it contains special characters.
|
|
79
|
+
|
|
80
|
+
- Values containing ';', '{', '}', '=', or spaces should be braced for safety
|
|
81
|
+
- '}' inside braced values is escaped as '}}'
|
|
82
|
+
- '{' does not need to be escaped
|
|
83
|
+
|
|
84
|
+
Args:
|
|
85
|
+
value: Parameter value to escape
|
|
86
|
+
|
|
87
|
+
Returns:
|
|
88
|
+
Escaped value (possibly wrapped in braces)
|
|
89
|
+
|
|
90
|
+
Examples:
|
|
91
|
+
>>> builder = _ConnectionStringBuilder()
|
|
92
|
+
>>> builder._escape_value("localhost")
|
|
93
|
+
'localhost'
|
|
94
|
+
>>> builder._escape_value("local;host")
|
|
95
|
+
'{local;host}'
|
|
96
|
+
>>> builder._escape_value("p}w{d")
|
|
97
|
+
'{p}}w{d}'
|
|
98
|
+
>>> builder._escape_value("ODBC Driver 18 for SQL Server")
|
|
99
|
+
'{ODBC Driver 18 for SQL Server}'
|
|
100
|
+
"""
|
|
101
|
+
if not value:
|
|
102
|
+
return value
|
|
103
|
+
|
|
104
|
+
# Check if value contains special characters that require bracing
|
|
105
|
+
# Include spaces and = for safety, even though technically not always required
|
|
106
|
+
needs_braces = any(ch in value for ch in ";{}= ")
|
|
107
|
+
|
|
108
|
+
if needs_braces:
|
|
109
|
+
# Escape closing braces by doubling them (ODBC requirement)
|
|
110
|
+
# Opening braces do not need to be escaped
|
|
111
|
+
escaped = value.replace("}", "}}")
|
|
112
|
+
return f"{{{escaped}}}"
|
|
113
|
+
else:
|
|
114
|
+
return value
|
|
@@ -0,0 +1,424 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Copyright (c) Microsoft Corporation.
|
|
3
|
+
Licensed under the MIT license.
|
|
4
|
+
|
|
5
|
+
ODBC connection string parser for mssql-python.
|
|
6
|
+
|
|
7
|
+
Handles ODBC-specific syntax per MS-ODBCSTR specification:
|
|
8
|
+
- Semicolon-separated key=value pairs
|
|
9
|
+
- Braced values: {value}
|
|
10
|
+
- Escaped braces: }} → } (only closing braces need escaping)
|
|
11
|
+
|
|
12
|
+
Parser behavior:
|
|
13
|
+
- Validates all key=value pairs
|
|
14
|
+
- Raises exceptions for malformed syntax (missing values, unknown keywords, duplicates)
|
|
15
|
+
- Collects all errors and reports them together
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from typing import Dict, Tuple, Optional
|
|
19
|
+
from mssql_python.exceptions import ConnectionStringParseError
|
|
20
|
+
from mssql_python.constants import _ALLOWED_CONNECTION_STRING_PARAMS, _RESERVED_PARAMETERS
|
|
21
|
+
from mssql_python.helpers import sanitize_user_input
|
|
22
|
+
from mssql_python.logging import logger
|
|
23
|
+
|
|
24
|
+
_SENSITIVE_KEYS = frozenset({"pwd", "password"})
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class _ConnectionStringParser:
|
|
28
|
+
"""
|
|
29
|
+
Internal parser for ODBC connection strings. Not part of public API.
|
|
30
|
+
|
|
31
|
+
Implements the ODBC Connection String format as specified in MS-ODBCSTR.
|
|
32
|
+
Handles braced values, escaped characters, and proper tokenization.
|
|
33
|
+
|
|
34
|
+
Validates connection strings and raises errors for:
|
|
35
|
+
- Unknown/unrecognized keywords
|
|
36
|
+
- Duplicate keywords
|
|
37
|
+
- Incomplete specifications (keyword with no value)
|
|
38
|
+
|
|
39
|
+
Reference: https://learn.microsoft.com/en-us/openspecs/sql_server_protocols/ms-odbcstr/55953f0e-2d30-4ad4-8e56-b4207e491409
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
def __init__(self, validate_keywords: bool = False) -> None:
|
|
43
|
+
"""
|
|
44
|
+
Initialize the parser.
|
|
45
|
+
|
|
46
|
+
Args:
|
|
47
|
+
validate_keywords: Whether to validate keywords against the allow-list.
|
|
48
|
+
If False, pure parsing without validation is performed.
|
|
49
|
+
This is useful for testing parsing logic independently
|
|
50
|
+
or when validation is handled separately.
|
|
51
|
+
"""
|
|
52
|
+
self._validate_keywords = validate_keywords
|
|
53
|
+
|
|
54
|
+
@classmethod
|
|
55
|
+
def normalize_key(cls, key: str) -> Optional[str]:
|
|
56
|
+
"""
|
|
57
|
+
Normalize a parameter key to its canonical form.
|
|
58
|
+
|
|
59
|
+
Args:
|
|
60
|
+
key: Parameter key from connection string (case-insensitive)
|
|
61
|
+
|
|
62
|
+
Returns:
|
|
63
|
+
Canonical parameter name if allowed, None otherwise
|
|
64
|
+
|
|
65
|
+
Examples:
|
|
66
|
+
>>> _ConnectionStringParser.normalize_key('SERVER')
|
|
67
|
+
'Server'
|
|
68
|
+
>>> _ConnectionStringParser.normalize_key('uid')
|
|
69
|
+
'UID'
|
|
70
|
+
>>> _ConnectionStringParser.normalize_key('UnsupportedParam')
|
|
71
|
+
None
|
|
72
|
+
"""
|
|
73
|
+
key_lower = key.lower().strip()
|
|
74
|
+
return _ALLOWED_CONNECTION_STRING_PARAMS.get(key_lower)
|
|
75
|
+
|
|
76
|
+
@staticmethod
|
|
77
|
+
def _normalize_params(params: Dict[str, str], warn_rejected: bool = True) -> Dict[str, str]:
|
|
78
|
+
"""
|
|
79
|
+
Normalize and filter parameters against the allow-list (internal use only).
|
|
80
|
+
|
|
81
|
+
This method performs several operations:
|
|
82
|
+
- Normalizes parameter names (e.g., addr/address → Server, uid → UID)
|
|
83
|
+
- Filters out parameters not in the allow-list
|
|
84
|
+
- Removes reserved parameters (Driver, APP)
|
|
85
|
+
- Deduplicates via normalized keys
|
|
86
|
+
|
|
87
|
+
Args:
|
|
88
|
+
params: Dictionary of connection string parameters (keys should be lowercase)
|
|
89
|
+
warn_rejected: Whether to log warnings for rejected parameters
|
|
90
|
+
|
|
91
|
+
Returns:
|
|
92
|
+
Dictionary containing only allowed parameters with normalized keys
|
|
93
|
+
|
|
94
|
+
Note:
|
|
95
|
+
Driver and APP parameters are filtered here but will be set by
|
|
96
|
+
the driver in _construct_connection_string to maintain control.
|
|
97
|
+
"""
|
|
98
|
+
filtered = {}
|
|
99
|
+
|
|
100
|
+
# The rejected list should ideally be empty when used in the normal connection
|
|
101
|
+
# flow, since the parser validates against the allowlist first and raises
|
|
102
|
+
# errors for unknown parameters. This filtering is primarily a safety net.
|
|
103
|
+
rejected = []
|
|
104
|
+
|
|
105
|
+
for key, value in params.items():
|
|
106
|
+
normalized_key = _ConnectionStringParser.normalize_key(key)
|
|
107
|
+
|
|
108
|
+
if normalized_key:
|
|
109
|
+
# Skip Driver and APP - these are controlled by the driver
|
|
110
|
+
if normalized_key in _RESERVED_PARAMETERS:
|
|
111
|
+
continue
|
|
112
|
+
|
|
113
|
+
# First-wins: match ODBC behaviour where the first
|
|
114
|
+
# occurrence of a synonym group takes precedence.
|
|
115
|
+
if normalized_key not in filtered:
|
|
116
|
+
filtered[normalized_key] = value
|
|
117
|
+
else:
|
|
118
|
+
# Parameter is not in allow-list
|
|
119
|
+
# Note: In normal flow, this should be empty since parser validates first
|
|
120
|
+
rejected.append(key)
|
|
121
|
+
|
|
122
|
+
# Log all rejected parameters together if any were found
|
|
123
|
+
if rejected and warn_rejected:
|
|
124
|
+
safe_keys = [sanitize_user_input(key) for key in rejected]
|
|
125
|
+
logger.debug(
|
|
126
|
+
f"Connection string parameters not in allow-list and will be ignored: {', '.join(safe_keys)}"
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
return filtered
|
|
130
|
+
|
|
131
|
+
def _parse(self, connection_str: str) -> Dict[str, str]:
|
|
132
|
+
"""
|
|
133
|
+
Parse a connection string into a dictionary of parameters.
|
|
134
|
+
|
|
135
|
+
Validates the connection string and raises ConnectionStringParseError
|
|
136
|
+
if any issues are found (unknown keywords, duplicates, missing values).
|
|
137
|
+
|
|
138
|
+
Args:
|
|
139
|
+
connection_str: ODBC-format connection string
|
|
140
|
+
|
|
141
|
+
Returns:
|
|
142
|
+
Dictionary mapping parameter names (lowercase) to values
|
|
143
|
+
|
|
144
|
+
Raises:
|
|
145
|
+
ConnectionStringParseError: If validation errors are found
|
|
146
|
+
|
|
147
|
+
Examples:
|
|
148
|
+
>>> parser = _ConnectionStringParser()
|
|
149
|
+
>>> result = parser._parse("Server=localhost;Database=mydb")
|
|
150
|
+
{'server': 'localhost', 'database': 'mydb'}
|
|
151
|
+
|
|
152
|
+
>>> parser._parse("Server={;local;};PWD={p}}w{{d}")
|
|
153
|
+
{'server': ';local;', 'pwd': 'p}w{d'}
|
|
154
|
+
|
|
155
|
+
>>> parser._parse("Server=localhost;Server=other")
|
|
156
|
+
ConnectionStringParseError: Duplicate keyword 'server'
|
|
157
|
+
"""
|
|
158
|
+
if not connection_str:
|
|
159
|
+
return {}
|
|
160
|
+
|
|
161
|
+
connection_str = connection_str.strip()
|
|
162
|
+
if not connection_str:
|
|
163
|
+
return {}
|
|
164
|
+
|
|
165
|
+
# Collect all errors for batch reporting
|
|
166
|
+
errors = []
|
|
167
|
+
|
|
168
|
+
# Dictionary to store parsed key=value pairs
|
|
169
|
+
params = {}
|
|
170
|
+
|
|
171
|
+
# Track which keys we've seen to detect duplicates
|
|
172
|
+
seen_keys = {} # Maps normalized key -> first occurrence position
|
|
173
|
+
|
|
174
|
+
# Track current position in the string
|
|
175
|
+
current_pos = 0
|
|
176
|
+
str_len = len(connection_str)
|
|
177
|
+
|
|
178
|
+
# Main parsing loop
|
|
179
|
+
while current_pos < str_len:
|
|
180
|
+
# Skip leading whitespace and semicolons
|
|
181
|
+
while current_pos < str_len and connection_str[current_pos] in " \t;":
|
|
182
|
+
current_pos += 1
|
|
183
|
+
|
|
184
|
+
if current_pos >= str_len:
|
|
185
|
+
break
|
|
186
|
+
|
|
187
|
+
# Parse the key
|
|
188
|
+
key_start = current_pos
|
|
189
|
+
|
|
190
|
+
# Advance until we hit '=', ';', or end of string
|
|
191
|
+
while current_pos < str_len and connection_str[current_pos] not in "=;":
|
|
192
|
+
current_pos += 1
|
|
193
|
+
|
|
194
|
+
# Check if we found a valid '=' separator
|
|
195
|
+
if current_pos >= str_len or connection_str[current_pos] != "=":
|
|
196
|
+
# ERROR: No '=' found - incomplete specification
|
|
197
|
+
incomplete_text = connection_str[key_start:current_pos].strip()
|
|
198
|
+
if incomplete_text:
|
|
199
|
+
errors.append(
|
|
200
|
+
f"Incomplete specification: keyword '{incomplete_text}' has no value (missing '=')"
|
|
201
|
+
)
|
|
202
|
+
# Skip to next semicolon
|
|
203
|
+
while current_pos < str_len and connection_str[current_pos] != ";":
|
|
204
|
+
current_pos += 1
|
|
205
|
+
continue
|
|
206
|
+
|
|
207
|
+
# Extract and normalize the key
|
|
208
|
+
key = connection_str[key_start:current_pos].strip().lower()
|
|
209
|
+
|
|
210
|
+
# ERROR: Empty key
|
|
211
|
+
if not key:
|
|
212
|
+
errors.append("Empty keyword found (format: =value)")
|
|
213
|
+
current_pos += 1 # Skip the '='
|
|
214
|
+
# Skip to next semicolon
|
|
215
|
+
while current_pos < str_len and connection_str[current_pos] != ";":
|
|
216
|
+
current_pos += 1
|
|
217
|
+
continue
|
|
218
|
+
|
|
219
|
+
# Move past the '='
|
|
220
|
+
current_pos += 1
|
|
221
|
+
|
|
222
|
+
# Parse the value
|
|
223
|
+
try:
|
|
224
|
+
value, current_pos = self._parse_value(connection_str, current_pos)
|
|
225
|
+
|
|
226
|
+
# ERROR: Empty value
|
|
227
|
+
if not value:
|
|
228
|
+
errors.append(
|
|
229
|
+
f"Empty value for keyword '{key}' (all connection string parameters must have non-empty values)"
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
# Check for duplicates
|
|
233
|
+
if key in seen_keys:
|
|
234
|
+
errors.append(f"Duplicate keyword '{key}' found")
|
|
235
|
+
else:
|
|
236
|
+
seen_keys[key] = True
|
|
237
|
+
params[key] = value
|
|
238
|
+
|
|
239
|
+
except ValueError as e:
|
|
240
|
+
errors.append(f"Error parsing value for keyword '{key}': {e}")
|
|
241
|
+
# Skip to next semicolon
|
|
242
|
+
while current_pos < str_len and connection_str[current_pos] != ";":
|
|
243
|
+
current_pos += 1
|
|
244
|
+
|
|
245
|
+
# Validate keywords against allowlist if validation is enabled
|
|
246
|
+
if self._validate_keywords:
|
|
247
|
+
unknown_keys = []
|
|
248
|
+
reserved_keys = []
|
|
249
|
+
|
|
250
|
+
for key in params.keys():
|
|
251
|
+
# Check if this key can be normalized (i.e., it's known)
|
|
252
|
+
normalized_key = _ConnectionStringParser.normalize_key(key)
|
|
253
|
+
|
|
254
|
+
if normalized_key is None:
|
|
255
|
+
# Unknown keyword
|
|
256
|
+
unknown_keys.append(key)
|
|
257
|
+
elif normalized_key in _RESERVED_PARAMETERS:
|
|
258
|
+
# Reserved keyword - user cannot set these
|
|
259
|
+
reserved_keys.append(key)
|
|
260
|
+
|
|
261
|
+
if reserved_keys:
|
|
262
|
+
for key in reserved_keys:
|
|
263
|
+
errors.append(
|
|
264
|
+
f"Reserved keyword '{key}' is controlled by the driver and cannot be specified by the user"
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
if unknown_keys:
|
|
268
|
+
for key in unknown_keys:
|
|
269
|
+
errors.append(f"Unknown keyword '{key}' is not recognized")
|
|
270
|
+
|
|
271
|
+
# If we collected any errors, raise them all together
|
|
272
|
+
if errors:
|
|
273
|
+
raise ConnectionStringParseError(errors)
|
|
274
|
+
|
|
275
|
+
return params
|
|
276
|
+
|
|
277
|
+
def _parse_value(self, connection_str: str, start_pos: int) -> Tuple[str, int]:
|
|
278
|
+
"""
|
|
279
|
+
Parse a parameter value from the connection string.
|
|
280
|
+
|
|
281
|
+
Handles both simple values and braced values with escaping.
|
|
282
|
+
|
|
283
|
+
Args:
|
|
284
|
+
connection_str: The connection string
|
|
285
|
+
start_pos: Starting position of the value
|
|
286
|
+
|
|
287
|
+
Returns:
|
|
288
|
+
Tuple of (parsed_value, new_position)
|
|
289
|
+
|
|
290
|
+
Raises:
|
|
291
|
+
ValueError: If braced value is not properly closed
|
|
292
|
+
"""
|
|
293
|
+
str_len = len(connection_str)
|
|
294
|
+
|
|
295
|
+
# Skip leading whitespace before the value
|
|
296
|
+
while start_pos < str_len and connection_str[start_pos] in " \t":
|
|
297
|
+
start_pos += 1
|
|
298
|
+
|
|
299
|
+
# If we've consumed the entire string or reached a semicolon, return empty value
|
|
300
|
+
if start_pos >= str_len:
|
|
301
|
+
return "", start_pos
|
|
302
|
+
|
|
303
|
+
# Determine if this is a braced value or simple value
|
|
304
|
+
if connection_str[start_pos] == "{":
|
|
305
|
+
return self._parse_braced_value(connection_str, start_pos)
|
|
306
|
+
else:
|
|
307
|
+
return self._parse_simple_value(connection_str, start_pos)
|
|
308
|
+
|
|
309
|
+
def _parse_simple_value(self, connection_str: str, start_pos: int) -> Tuple[str, int]:
|
|
310
|
+
"""
|
|
311
|
+
Parse a simple (non-braced) value up to the next semicolon.
|
|
312
|
+
|
|
313
|
+
Args:
|
|
314
|
+
connection_str: The connection string
|
|
315
|
+
start_pos: Starting position of the value
|
|
316
|
+
|
|
317
|
+
Returns:
|
|
318
|
+
Tuple of (parsed_value, new_position)
|
|
319
|
+
"""
|
|
320
|
+
str_len = len(connection_str)
|
|
321
|
+
value_start = start_pos
|
|
322
|
+
|
|
323
|
+
# Read characters until we hit a semicolon or end of string
|
|
324
|
+
while start_pos < str_len and connection_str[start_pos] != ";":
|
|
325
|
+
start_pos += 1
|
|
326
|
+
|
|
327
|
+
# Extract the value and strip trailing whitespace
|
|
328
|
+
value = connection_str[value_start:start_pos].rstrip()
|
|
329
|
+
return value, start_pos
|
|
330
|
+
|
|
331
|
+
def _parse_braced_value(self, connection_str: str, start_pos: int) -> Tuple[str, int]:
|
|
332
|
+
"""
|
|
333
|
+
Parse a braced value with proper handling of escaped braces.
|
|
334
|
+
|
|
335
|
+
Braced values:
|
|
336
|
+
- Start with '{' and end with '}'
|
|
337
|
+
- '}' inside the value is escaped as '}}'
|
|
338
|
+
- '{' inside the value does not need escaping
|
|
339
|
+
- Can contain semicolons and other special characters
|
|
340
|
+
|
|
341
|
+
Args:
|
|
342
|
+
connection_str: The connection string
|
|
343
|
+
start_pos: Starting position (should point to opening '{')
|
|
344
|
+
|
|
345
|
+
Returns:
|
|
346
|
+
Tuple of (parsed_value, new_position)
|
|
347
|
+
|
|
348
|
+
Raises:
|
|
349
|
+
ValueError: If the braced value is not closed (missing '}')
|
|
350
|
+
"""
|
|
351
|
+
str_len = len(connection_str)
|
|
352
|
+
brace_start_pos = start_pos
|
|
353
|
+
|
|
354
|
+
# Skip the opening '{'
|
|
355
|
+
start_pos += 1
|
|
356
|
+
|
|
357
|
+
# Build the value character by character
|
|
358
|
+
value = []
|
|
359
|
+
|
|
360
|
+
while start_pos < str_len:
|
|
361
|
+
ch = connection_str[start_pos]
|
|
362
|
+
|
|
363
|
+
if ch == "}":
|
|
364
|
+
# Check if next character is also '}' (escaped brace)
|
|
365
|
+
if start_pos + 1 < str_len and connection_str[start_pos + 1] == "}":
|
|
366
|
+
# Escaped right brace: '}}' → '}'
|
|
367
|
+
value.append("}")
|
|
368
|
+
start_pos += 2
|
|
369
|
+
else:
|
|
370
|
+
# Single '}' means end of braced value
|
|
371
|
+
start_pos += 1
|
|
372
|
+
return "".join(value), start_pos
|
|
373
|
+
else:
|
|
374
|
+
# Regular character (including '{' which doesn't need escaping per ODBC spec)
|
|
375
|
+
value.append(ch)
|
|
376
|
+
start_pos += 1
|
|
377
|
+
|
|
378
|
+
# Reached end without finding closing '}'
|
|
379
|
+
raise ValueError(f"Unclosed braced value starting at position {brace_start_pos}")
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def sanitize_connection_string(conn_str: str) -> str:
|
|
383
|
+
"""
|
|
384
|
+
Sanitize a connection string by masking sensitive values (PWD, Password).
|
|
385
|
+
|
|
386
|
+
Uses _ConnectionStringParser to correctly handle ODBC braced values
|
|
387
|
+
(e.g. PWD={Top;Secret}) rather than a simple regex, which would truncate
|
|
388
|
+
at the first semicolon and leak the tail of the password.
|
|
389
|
+
|
|
390
|
+
If parsing fails (malformed input), the entire string is redacted to
|
|
391
|
+
prevent any partial password leakage.
|
|
392
|
+
|
|
393
|
+
Args:
|
|
394
|
+
conn_str (str): The connection string to sanitize.
|
|
395
|
+
Returns:
|
|
396
|
+
str: The sanitized connection string.
|
|
397
|
+
"""
|
|
398
|
+
from mssql_python.connection_string_builder import _ConnectionStringBuilder
|
|
399
|
+
|
|
400
|
+
logger.debug(
|
|
401
|
+
"sanitize_connection_string: Sanitizing connection string (length=%d)", len(conn_str)
|
|
402
|
+
)
|
|
403
|
+
|
|
404
|
+
try:
|
|
405
|
+
parser = _ConnectionStringParser(validate_keywords=False)
|
|
406
|
+
params = parser._parse(conn_str)
|
|
407
|
+
|
|
408
|
+
sanitized_params = {}
|
|
409
|
+
for key, value in params.items():
|
|
410
|
+
canonical = _ConnectionStringParser.normalize_key(key)
|
|
411
|
+
display_key = canonical if canonical else key
|
|
412
|
+
if key in _SENSITIVE_KEYS:
|
|
413
|
+
sanitized_params[display_key] = "***"
|
|
414
|
+
else:
|
|
415
|
+
sanitized_params[display_key] = value
|
|
416
|
+
|
|
417
|
+
builder = _ConnectionStringBuilder(sanitized_params)
|
|
418
|
+
sanitized = builder.build()
|
|
419
|
+
except Exception:
|
|
420
|
+
logger.debug("sanitize_connection_string: Failed to parse, redacting entire string")
|
|
421
|
+
sanitized = "<redacted – unparseable connection string>"
|
|
422
|
+
|
|
423
|
+
logger.debug("sanitize_connection_string: Password fields masked")
|
|
424
|
+
return sanitized
|