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.
@@ -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