mssql-python 1.0.0__cp314-cp314-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.
Files changed (73) hide show
  1. mssql_python/__init__.py +307 -0
  2. mssql_python/auth.py +222 -0
  3. mssql_python/connection.py +1493 -0
  4. mssql_python/connection_string_builder.py +113 -0
  5. mssql_python/connection_string_parser.py +381 -0
  6. mssql_python/constants.py +509 -0
  7. mssql_python/cursor.py +2601 -0
  8. mssql_python/db_connection.py +53 -0
  9. mssql_python/ddbc_bindings.cp314-amd64.pyd +0 -0
  10. mssql_python/ddbc_bindings.py +137 -0
  11. mssql_python/exceptions.py +574 -0
  12. mssql_python/helpers.py +357 -0
  13. mssql_python/libs/LICENSING +19 -0
  14. mssql_python/libs/linux/alpine/arm64/lib/MICROSOFT_ODBC_DRIVER_FOR_SQL_SERVER_LICENSE.txt +76 -0
  15. mssql_python/libs/linux/alpine/arm64/lib/libmsodbcsql-18.5.so.1.1 +0 -0
  16. mssql_python/libs/linux/alpine/arm64/lib/libodbcinst.so.2 +0 -0
  17. mssql_python/libs/linux/alpine/arm64/share/resources/en_US/msodbcsqlr18.rll +0 -0
  18. mssql_python/libs/linux/alpine/x86_64/lib/MICROSOFT_ODBC_DRIVER_FOR_SQL_SERVER_LICENSE.txt +76 -0
  19. mssql_python/libs/linux/alpine/x86_64/lib/libmsodbcsql-18.5.so.1.1 +0 -0
  20. mssql_python/libs/linux/alpine/x86_64/lib/libodbcinst.so.2 +0 -0
  21. mssql_python/libs/linux/alpine/x86_64/share/resources/en_US/msodbcsqlr18.rll +0 -0
  22. mssql_python/libs/linux/debian_ubuntu/arm64/lib/MICROSOFT_ODBC_DRIVER_FOR_SQL_SERVER_LICENSE.txt +76 -0
  23. mssql_python/libs/linux/debian_ubuntu/arm64/lib/libmsodbcsql-18.5.so.1.1 +0 -0
  24. mssql_python/libs/linux/debian_ubuntu/arm64/lib/libodbcinst.so.2 +0 -0
  25. mssql_python/libs/linux/debian_ubuntu/arm64/share/resources/en_US/msodbcsqlr18.rll +0 -0
  26. mssql_python/libs/linux/debian_ubuntu/x86_64/lib/MICROSOFT_ODBC_DRIVER_FOR_SQL_SERVER_LICENSE.txt +76 -0
  27. mssql_python/libs/linux/debian_ubuntu/x86_64/lib/libmsodbcsql-18.5.so.1.1 +0 -0
  28. mssql_python/libs/linux/debian_ubuntu/x86_64/lib/libodbcinst.so.2 +0 -0
  29. mssql_python/libs/linux/debian_ubuntu/x86_64/share/resources/en_US/msodbcsqlr18.rll +0 -0
  30. mssql_python/libs/linux/rhel/arm64/lib/MICROSOFT_ODBC_DRIVER_FOR_SQL_SERVER_LICENSE.txt +76 -0
  31. mssql_python/libs/linux/rhel/arm64/lib/libmsodbcsql-18.5.so.1.1 +0 -0
  32. mssql_python/libs/linux/rhel/arm64/lib/libodbcinst.so.2 +0 -0
  33. mssql_python/libs/linux/rhel/arm64/share/resources/en_US/msodbcsqlr18.rll +0 -0
  34. mssql_python/libs/linux/rhel/x86_64/lib/MICROSOFT_ODBC_DRIVER_FOR_SQL_SERVER_LICENSE.txt +76 -0
  35. mssql_python/libs/linux/rhel/x86_64/lib/libmsodbcsql-18.5.so.1.1 +0 -0
  36. mssql_python/libs/linux/rhel/x86_64/lib/libodbcinst.so.2 +0 -0
  37. mssql_python/libs/linux/rhel/x86_64/share/resources/en_US/msodbcsqlr18.rll +0 -0
  38. mssql_python/libs/linux/suse/x86_64/lib/libmsodbcsql-18.5.so.1.1 +0 -0
  39. mssql_python/libs/linux/suse/x86_64/lib/libodbcinst.so.2 +0 -0
  40. mssql_python/libs/linux/suse/x86_64/share/resources/en_US/msodbcsqlr18.rll +0 -0
  41. mssql_python/libs/macos/arm64/lib/MICROSOFT_ODBC_DRIVER_FOR_SQL_SERVER_LICENSE.txt +76 -0
  42. mssql_python/libs/macos/arm64/lib/libltdl.7.dylib +0 -0
  43. mssql_python/libs/macos/arm64/lib/libmsodbcsql.18.dylib +0 -0
  44. mssql_python/libs/macos/arm64/lib/libodbc.2.dylib +0 -0
  45. mssql_python/libs/macos/arm64/lib/libodbcinst.2.dylib +0 -0
  46. mssql_python/libs/macos/arm64/share/doc/msodbcsql18/LICENSE.txt +101 -0
  47. mssql_python/libs/macos/arm64/share/doc/msodbcsql18/RELEASE_NOTES +465 -0
  48. mssql_python/libs/macos/arm64/share/msodbcsql18/resources/en_US/msodbcsqlr18.rll +0 -0
  49. mssql_python/libs/macos/x86_64/lib/MICROSOFT_ODBC_DRIVER_FOR_SQL_SERVER_LICENSE.txt +76 -0
  50. mssql_python/libs/macos/x86_64/lib/libltdl.7.dylib +0 -0
  51. mssql_python/libs/macos/x86_64/lib/libmsodbcsql.18.dylib +0 -0
  52. mssql_python/libs/macos/x86_64/lib/libodbc.2.dylib +0 -0
  53. mssql_python/libs/macos/x86_64/lib/libodbcinst.2.dylib +0 -0
  54. mssql_python/libs/macos/x86_64/share/doc/msodbcsql18/LICENSE.txt +101 -0
  55. mssql_python/libs/macos/x86_64/share/doc/msodbcsql18/RELEASE_NOTES +465 -0
  56. mssql_python/libs/macos/x86_64/share/msodbcsql18/resources/en_US/msodbcsqlr18.rll +0 -0
  57. mssql_python/libs/windows/x64/1033/msodbcsqlr18.rll +0 -0
  58. mssql_python/libs/windows/x64/MICROSOFT_ODBC_DRIVER_FOR_SQL_SERVER_LICENSE.txt +76 -0
  59. mssql_python/libs/windows/x64/MICROSOFT_VISUAL_STUDIO_LICENSE.txt +54 -0
  60. mssql_python/libs/windows/x64/msodbcdiag18.dll +0 -0
  61. mssql_python/libs/windows/x64/msodbcsql18.dll +0 -0
  62. mssql_python/libs/windows/x64/mssql-auth.dll +0 -0
  63. mssql_python/logging.py +610 -0
  64. mssql_python/mssql_python.pyi +375 -0
  65. mssql_python/msvcp140.dll +0 -0
  66. mssql_python/pooling.py +125 -0
  67. mssql_python/row.py +194 -0
  68. mssql_python/type.py +143 -0
  69. mssql_python-1.0.0.dist-info/METADATA +76 -0
  70. mssql_python-1.0.0.dist-info/RECORD +73 -0
  71. mssql_python-1.0.0.dist-info/WHEEL +5 -0
  72. mssql_python-1.0.0.dist-info/licenses/LICENSE +47 -0
  73. mssql_python-1.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,1493 @@
1
+ """
2
+ Copyright (c) Microsoft Corporation.
3
+ Licensed under the MIT license.
4
+ This module defines the Connection class, which is used to manage a connection to a database.
5
+ The class provides methods to establish a connection, create cursors, commit transactions,
6
+ roll back transactions, and close the connection.
7
+ Resource Management:
8
+ - All cursors created from this connection are tracked internally.
9
+ - When close() is called on the connection, all open cursors are automatically closed.
10
+ - Do not use any cursor after the connection is closed; doing so will raise an exception.
11
+ - Cursors are also cleaned up automatically when no longer referenced, to prevent memory leaks.
12
+ """
13
+
14
+ import weakref
15
+ import re
16
+ import codecs
17
+ from typing import Any, Dict, Optional, Union, List, Tuple, Callable, TYPE_CHECKING
18
+ import threading
19
+
20
+ from mssql_python.cursor import Cursor
21
+ from mssql_python.helpers import (
22
+ add_driver_to_connection_str,
23
+ sanitize_connection_string,
24
+ sanitize_user_input,
25
+ validate_attribute_value,
26
+ )
27
+ from mssql_python.logging import logger
28
+ from mssql_python import ddbc_bindings
29
+ from mssql_python.pooling import PoolingManager
30
+ from mssql_python.exceptions import (
31
+ Warning, # pylint: disable=redefined-builtin
32
+ Error,
33
+ InterfaceError,
34
+ DatabaseError,
35
+ DataError,
36
+ OperationalError,
37
+ IntegrityError,
38
+ InternalError,
39
+ ProgrammingError,
40
+ NotSupportedError,
41
+ )
42
+ from mssql_python.auth import process_connection_string
43
+ from mssql_python.constants import ConstantsDDBC, GetInfoConstants
44
+ from mssql_python.connection_string_parser import _ConnectionStringParser
45
+ from mssql_python.connection_string_builder import _ConnectionStringBuilder
46
+ from mssql_python.constants import _RESERVED_PARAMETERS
47
+
48
+ if TYPE_CHECKING:
49
+ from mssql_python.row import Row
50
+
51
+ # Add SQL_WMETADATA constant for metadata decoding configuration
52
+ SQL_WMETADATA: int = -99 # Special flag for column name decoding
53
+ # Threshold to determine if an info type is string-based
54
+ INFO_TYPE_STRING_THRESHOLD: int = 10000
55
+
56
+ # UTF-16 encoding variants that should use SQL_WCHAR by default
57
+ UTF16_ENCODINGS: frozenset[str] = frozenset(["utf-16", "utf-16le", "utf-16be"])
58
+
59
+
60
+ def _validate_encoding(encoding: str) -> bool:
61
+ """
62
+ Cached encoding validation using codecs.lookup().
63
+
64
+ Args:
65
+ encoding (str): The encoding name to validate.
66
+
67
+ Returns:
68
+ bool: True if encoding is valid, False otherwise.
69
+
70
+ Note:
71
+ Uses LRU cache to avoid repeated expensive codecs.lookup() calls.
72
+ Cache size is limited to 128 entries which should cover most use cases.
73
+ """
74
+ try:
75
+ codecs.lookup(encoding)
76
+ return True
77
+ except LookupError:
78
+ return False
79
+
80
+
81
+ class Connection:
82
+ """
83
+ A class to manage a connection to a database, compliant with DB-API 2.0 specifications.
84
+
85
+ This class provides methods to establish a connection to a database, create cursors,
86
+ commit transactions, roll back transactions, and close the connection. It is designed
87
+ to be used in a context where database operations are required, such as executing queries
88
+ and fetching results.
89
+
90
+ The Connection class supports the Python context manager protocol (with statement).
91
+ When used as a context manager, it will automatically close the connection when
92
+ exiting the context, ensuring proper resource cleanup.
93
+
94
+ Example usage:
95
+ with connect(connection_string) as conn:
96
+ cursor = conn.cursor()
97
+ cursor.execute("INSERT INTO table VALUES (?)", [value])
98
+ # Connection is automatically closed when exiting the with block
99
+
100
+ For long-lived connections, use without context manager:
101
+ conn = connect(connection_string)
102
+ try:
103
+ # Multiple operations...
104
+ finally:
105
+ conn.close()
106
+
107
+ Methods:
108
+ __init__(database: str) -> None:
109
+ connect_to_db() -> None:
110
+ cursor() -> Cursor:
111
+ commit() -> None:
112
+ rollback() -> None:
113
+ close() -> None:
114
+ __enter__() -> Connection:
115
+ __exit__() -> None:
116
+ setencoding(encoding=None, ctype=None) -> None:
117
+ setdecoding(sqltype, encoding=None, ctype=None) -> None:
118
+ getdecoding(sqltype) -> dict:
119
+ set_attr(attribute, value) -> None:
120
+ """
121
+
122
+ # DB-API 2.0 Exception attributes
123
+ # These allow users to catch exceptions using connection.Error,
124
+ # connection.ProgrammingError, etc.
125
+ Warning = Warning
126
+ Error = Error
127
+ InterfaceError = InterfaceError
128
+ DatabaseError = DatabaseError
129
+ DataError = DataError
130
+ OperationalError = OperationalError
131
+ IntegrityError = IntegrityError
132
+ InternalError = InternalError
133
+ ProgrammingError = ProgrammingError
134
+ NotSupportedError = NotSupportedError
135
+
136
+ def __init__(
137
+ self,
138
+ connection_str: str = "",
139
+ autocommit: bool = False,
140
+ attrs_before: Optional[Dict[int, Union[int, str, bytes]]] = None,
141
+ timeout: int = 0,
142
+ **kwargs: Any,
143
+ ) -> None:
144
+ """
145
+ Initialize the connection object with the specified connection string and parameters.
146
+
147
+ Args:
148
+ connection_str (str): The connection string to connect to.
149
+ autocommit (bool): If True, causes a commit to be performed after
150
+ each SQL statement.
151
+ attrs_before (dict, optional): Dictionary of connection attributes to set before
152
+ connection establishment. Keys are SQL_ATTR_* constants,
153
+ and values are their corresponding settings.
154
+ Use this for attributes that must be set before
155
+ connecting, such as SQL_ATTR_LOGIN_TIMEOUT,
156
+ SQL_ATTR_ODBC_CURSORS, and SQL_ATTR_PACKET_SIZE.
157
+ timeout (int): Login timeout in seconds. 0 means no timeout.
158
+ **kwargs: Additional key/value pairs for the connection string.
159
+
160
+ Returns:
161
+ None
162
+
163
+ Raises:
164
+ ValueError: If the connection string is invalid or connection fails.
165
+
166
+ This method sets up the initial state for the connection object,
167
+ preparing it for further operations such as connecting to the
168
+ database, executing queries, etc.
169
+
170
+ Example:
171
+ >>> # Setting login timeout using attrs_before
172
+ >>> import mssql_python as ms
173
+ >>> conn = ms.connect("Server=myserver;Database=mydb",
174
+ ... attrs_before={ms.SQL_ATTR_LOGIN_TIMEOUT: 30})
175
+ """
176
+ self.connection_str = self._construct_connection_string(
177
+ connection_str, **kwargs
178
+ )
179
+ self._attrs_before = attrs_before or {}
180
+
181
+ # Initialize encoding settings with defaults for Python 3
182
+ # Python 3 only has str (which is Unicode), so we use utf-16le by default
183
+ self._encoding_settings = {
184
+ "encoding": "utf-16le",
185
+ "ctype": ConstantsDDBC.SQL_WCHAR.value,
186
+ }
187
+
188
+ # Initialize decoding settings with Python 3 defaults
189
+ self._decoding_settings = {
190
+ ConstantsDDBC.SQL_CHAR.value: {
191
+ "encoding": "utf-8",
192
+ "ctype": ConstantsDDBC.SQL_CHAR.value,
193
+ },
194
+ ConstantsDDBC.SQL_WCHAR.value: {
195
+ "encoding": "utf-16le",
196
+ "ctype": ConstantsDDBC.SQL_WCHAR.value,
197
+ },
198
+ SQL_WMETADATA: {
199
+ "encoding": "utf-16le",
200
+ "ctype": ConstantsDDBC.SQL_WCHAR.value,
201
+ },
202
+ }
203
+
204
+ # Check if the connection string contains authentication parameters
205
+ # This is important for processing the connection string correctly.
206
+ # If authentication is specified, it will be processed to handle
207
+ # different authentication types like interactive, device code, etc.
208
+ if re.search(r"authentication", self.connection_str, re.IGNORECASE):
209
+ connection_result = process_connection_string(self.connection_str)
210
+ self.connection_str = connection_result[0]
211
+ if connection_result[1]:
212
+ self._attrs_before.update(connection_result[1])
213
+
214
+ self._closed = False
215
+ self._timeout = timeout
216
+
217
+ # Using WeakSet which automatically removes cursors when they are no
218
+ # longer in use
219
+ # It is a set that holds weak references to its elements.
220
+ # When an object is only weakly referenced, it can be garbage
221
+ # collected even if it's still in the set.
222
+ # It prevents memory leaks by ensuring that cursors are cleaned up
223
+ # when no longer in use without requiring explicit deletion.
224
+ # TODO: Think and implement scenarios for multi-threaded access
225
+ # to cursors
226
+ self._cursors = weakref.WeakSet()
227
+
228
+ # Initialize output converters dictionary and its lock for thread safety
229
+ self._output_converters = {}
230
+ self._converters_lock = threading.Lock()
231
+
232
+ # Initialize search escape character
233
+ self._searchescape = None
234
+
235
+ # Auto-enable pooling if user never called
236
+ if not PoolingManager.is_initialized():
237
+ PoolingManager.enable()
238
+ self._pooling = PoolingManager.is_enabled()
239
+ self._conn = ddbc_bindings.Connection(
240
+ self.connection_str, self._pooling, self._attrs_before
241
+ )
242
+ self.setautocommit(autocommit)
243
+
244
+ def _construct_connection_string(
245
+ self, connection_str: str = "", **kwargs: Any
246
+ ) -> str:
247
+ """
248
+ Construct the connection string by parsing, validating, and merging parameters.
249
+
250
+ This method performs a 6-step process:
251
+ 1. Parse and validate the base connection_str (validates against allowlist)
252
+ 2. Normalize parameter names (e.g., addr/address -> Server, uid -> UID)
253
+ 3. Merge kwargs (which override connection_str params after normalization)
254
+ 4. Build connection string from normalized, merged params
255
+ 5. Add Driver and APP parameters (always controlled by the driver)
256
+ 6. Return the final connection string
257
+
258
+ Args:
259
+ connection_str (str): The base connection string.
260
+ **kwargs: Additional key/value pairs for the connection string.
261
+
262
+ Returns:
263
+ str: The constructed and validated connection string.
264
+ """
265
+
266
+ # Step 1: Parse base connection string with allowlist validation
267
+ # The parser validates everything: unknown params, reserved params, duplicates, syntax
268
+ parser = _ConnectionStringParser(validate_keywords=True)
269
+ parsed_params = parser._parse(connection_str)
270
+
271
+ # Step 2: Normalize parameter names (e.g., addr/address -> Server, uid -> UID)
272
+ # This handles synonym mapping and deduplication via normalized keys
273
+ normalized_params = _ConnectionStringParser._normalize_params(parsed_params, warn_rejected=False)
274
+
275
+ # Step 3: Process kwargs and merge with normalized_params
276
+ # kwargs override connection string values (processed after, so they take precedence)
277
+ for key, value in kwargs.items():
278
+ normalized_key = _ConnectionStringParser.normalize_key(key)
279
+ if normalized_key:
280
+ # Driver and APP are reserved - raise error if user tries to set them
281
+ if normalized_key in _RESERVED_PARAMETERS:
282
+ raise ValueError(
283
+ f"Connection parameter '{key}' is reserved and controlled by the driver. "
284
+ f"It cannot be set by the user."
285
+ )
286
+ # kwargs override any existing values from connection string
287
+ normalized_params[normalized_key] = str(value)
288
+ else:
289
+ logger.warning(f"Ignoring unknown connection parameter from kwargs: {key}")
290
+
291
+ # Step 4: Build connection string with merged params
292
+ builder = _ConnectionStringBuilder(normalized_params)
293
+
294
+ # Step 5: Add Driver and APP parameters (always controlled by the driver)
295
+ # These maintain existing behavior: Driver is always hardcoded, APP is always MSSQL-Python
296
+ builder.add_param('Driver', 'ODBC Driver 18 for SQL Server')
297
+ builder.add_param('APP', 'MSSQL-Python')
298
+
299
+ # Step 6: Build final string
300
+ conn_str = builder.build()
301
+
302
+ logger.info("Final connection string: %s", sanitize_connection_string(conn_str))
303
+
304
+ return conn_str
305
+
306
+ @property
307
+ def timeout(self) -> int:
308
+ """
309
+ Get the current query timeout setting in seconds.
310
+
311
+ Returns:
312
+ int: The timeout value in seconds. Zero means no timeout (wait indefinitely).
313
+ """
314
+ return self._timeout
315
+
316
+ @timeout.setter
317
+ def timeout(self, value: int) -> None:
318
+ """
319
+ Set the query timeout for all operations performed by this connection.
320
+
321
+ Args:
322
+ value (int): The timeout value in seconds. Zero means no timeout.
323
+
324
+ Returns:
325
+ None
326
+
327
+ Note:
328
+ This timeout applies to all cursors created from this connection.
329
+ It cannot be changed for individual cursors or SQL statements.
330
+ If a query timeout occurs, an OperationalError exception will be raised.
331
+ """
332
+ if not isinstance(value, int):
333
+ raise TypeError("Timeout must be an integer")
334
+ if value < 0:
335
+ raise ValueError("Timeout cannot be negative")
336
+ self._timeout = value
337
+ logger.info( f"Query timeout set to {value} seconds")
338
+
339
+ @property
340
+ def autocommit(self) -> bool:
341
+ """
342
+ Return the current autocommit mode of the connection.
343
+ Returns:
344
+ bool: True if autocommit is enabled, False otherwise.
345
+ """
346
+ return self._conn.get_autocommit()
347
+
348
+ @autocommit.setter
349
+ def autocommit(self, value: bool) -> None:
350
+ """
351
+ Set the autocommit mode of the connection.
352
+ Args:
353
+ value (bool): True to enable autocommit, False to disable it.
354
+ Returns:
355
+ None
356
+ """
357
+ self.setautocommit(value)
358
+ logger.info( "Autocommit mode set to %s.", value)
359
+
360
+ def setautocommit(self, value: bool = False) -> None:
361
+ """
362
+ Set the autocommit mode of the connection.
363
+ Args:
364
+ value (bool): True to enable autocommit, False to disable it.
365
+ Returns:
366
+ None
367
+ Raises:
368
+ DatabaseError: If there is an error while setting the autocommit mode.
369
+ """
370
+ self._conn.set_autocommit(value)
371
+
372
+ def setencoding(
373
+ self, encoding: Optional[str] = None, ctype: Optional[int] = None
374
+ ) -> None:
375
+ """
376
+ Sets the text encoding for SQL statements and text parameters.
377
+
378
+ Since Python 3 only has str (which is Unicode), this method configures
379
+ how text is encoded when sending to the database.
380
+
381
+ Args:
382
+ encoding (str, optional): The encoding to use. This must be a valid Python
383
+ encoding that converts text to bytes. If None, defaults to 'utf-16le'.
384
+ ctype (int, optional): The C data type to use when passing data:
385
+ SQL_CHAR or SQL_WCHAR. If not provided, SQL_WCHAR is used for
386
+ UTF-16 variants (see UTF16_ENCODINGS constant). SQL_CHAR is used
387
+ for all other encodings.
388
+
389
+ Returns:
390
+ None
391
+
392
+ Raises:
393
+ ProgrammingError: If the encoding is not valid or not supported.
394
+ InterfaceError: If the connection is closed.
395
+
396
+ Example:
397
+ # For databases that only communicate with UTF-8
398
+ cnxn.setencoding(encoding='utf-8')
399
+
400
+ # For explicitly using SQL_CHAR
401
+ cnxn.setencoding(encoding='utf-8', ctype=mssql_python.SQL_CHAR)
402
+ """
403
+ logger.debug( 'setencoding: Configuring encoding=%s, ctype=%s',
404
+ str(encoding) if encoding else 'default', str(ctype) if ctype else 'auto')
405
+ if self._closed:
406
+ logger.debug( 'setencoding: Connection is closed')
407
+ raise InterfaceError(
408
+ driver_error="Connection is closed",
409
+ ddbc_error="Connection is closed",
410
+ )
411
+
412
+ # Set default encoding if not provided
413
+ if encoding is None:
414
+ encoding = "utf-16le"
415
+ logger.debug( 'setencoding: Using default encoding=utf-16le')
416
+
417
+ # Validate encoding using cached validation for better performance
418
+ if not _validate_encoding(encoding):
419
+ # Log the sanitized encoding for security
420
+ logger.debug(
421
+ "warning",
422
+ "Invalid encoding attempted: %s",
423
+ sanitize_user_input(str(encoding)),
424
+ )
425
+ raise ProgrammingError(
426
+ driver_error=f"Unsupported encoding: {encoding}",
427
+ ddbc_error=f"The encoding '{encoding}' is not supported by Python",
428
+ )
429
+
430
+ # Normalize encoding to casefold for more robust Unicode handling
431
+ encoding = encoding.casefold()
432
+ logger.debug( 'setencoding: Encoding normalized to %s', encoding)
433
+
434
+ # Set default ctype based on encoding if not provided
435
+ if ctype is None:
436
+ if encoding in UTF16_ENCODINGS:
437
+ ctype = ConstantsDDBC.SQL_WCHAR.value
438
+ logger.debug( 'setencoding: Auto-selected SQL_WCHAR for UTF-16')
439
+ else:
440
+ ctype = ConstantsDDBC.SQL_CHAR.value
441
+ logger.debug( 'setencoding: Auto-selected SQL_CHAR for non-UTF-16')
442
+
443
+ # Validate ctype
444
+ valid_ctypes = [ConstantsDDBC.SQL_CHAR.value, ConstantsDDBC.SQL_WCHAR.value]
445
+ if ctype not in valid_ctypes:
446
+ # Log the sanitized ctype for security
447
+ logger.debug(
448
+ "warning",
449
+ "Invalid ctype attempted: %s",
450
+ sanitize_user_input(str(ctype)),
451
+ )
452
+ raise ProgrammingError(
453
+ driver_error=f"Invalid ctype: {ctype}",
454
+ ddbc_error=(
455
+ f"ctype must be SQL_CHAR ({ConstantsDDBC.SQL_CHAR.value}) or "
456
+ f"SQL_WCHAR ({ConstantsDDBC.SQL_WCHAR.value})"
457
+ ),
458
+ )
459
+
460
+ # Store the encoding settings
461
+ self._encoding_settings = {"encoding": encoding, "ctype": ctype}
462
+
463
+ # Log with sanitized values for security
464
+ logger.debug(
465
+ "info",
466
+ "Text encoding set to %s with ctype %s",
467
+ sanitize_user_input(encoding),
468
+ sanitize_user_input(str(ctype)),
469
+ )
470
+
471
+ def getencoding(self) -> Dict[str, Union[str, int]]:
472
+ """
473
+ Gets the current text encoding settings.
474
+
475
+ Returns:
476
+ dict: A dictionary containing 'encoding' and 'ctype' keys.
477
+
478
+ Raises:
479
+ InterfaceError: If the connection is closed.
480
+
481
+ Example:
482
+ settings = cnxn.getencoding()
483
+ print(f"Current encoding: {settings['encoding']}")
484
+ print(f"Current ctype: {settings['ctype']}")
485
+ """
486
+ if self._closed:
487
+ raise InterfaceError(
488
+ driver_error="Connection is closed",
489
+ ddbc_error="Connection is closed",
490
+ )
491
+
492
+ return self._encoding_settings.copy()
493
+
494
+ def setdecoding(
495
+ self, sqltype: int, encoding: Optional[str] = None, ctype: Optional[int] = None
496
+ ) -> None:
497
+ """
498
+ Sets the text decoding used when reading SQL_CHAR and SQL_WCHAR from the database.
499
+
500
+ This method configures how text data is decoded when reading from the database.
501
+ In Python 3, all text is Unicode (str), so this primarily affects the encoding
502
+ used to decode bytes from the database.
503
+
504
+ Args:
505
+ sqltype (int): The SQL type being configured: SQL_CHAR, SQL_WCHAR, or SQL_WMETADATA.
506
+ SQL_WMETADATA is a special flag for configuring column name decoding.
507
+ encoding (str, optional): The Python encoding to use when decoding the data.
508
+ If None, uses default encoding based on sqltype.
509
+ ctype (int, optional): The C data type to request from SQLGetData:
510
+ SQL_CHAR or SQL_WCHAR. If None, uses default based on encoding.
511
+
512
+ Returns:
513
+ None
514
+
515
+ Raises:
516
+ ProgrammingError: If the sqltype, encoding, or ctype is invalid.
517
+ InterfaceError: If the connection is closed.
518
+
519
+ Example:
520
+ # Configure SQL_CHAR to use UTF-8 decoding
521
+ cnxn.setdecoding(mssql_python.SQL_CHAR, encoding='utf-8')
522
+
523
+ # Configure column metadata decoding
524
+ cnxn.setdecoding(mssql_python.SQL_WMETADATA, encoding='utf-16le')
525
+
526
+ # Use explicit ctype
527
+ cnxn.setdecoding(mssql_python.SQL_WCHAR, encoding='utf-16le',
528
+ ctype=mssql_python.SQL_WCHAR)
529
+ """
530
+ if self._closed:
531
+ raise InterfaceError(
532
+ driver_error="Connection is closed",
533
+ ddbc_error="Connection is closed",
534
+ )
535
+
536
+ # Validate sqltype
537
+ valid_sqltypes = [
538
+ ConstantsDDBC.SQL_CHAR.value,
539
+ ConstantsDDBC.SQL_WCHAR.value,
540
+ SQL_WMETADATA,
541
+ ]
542
+ if sqltype not in valid_sqltypes:
543
+ logger.debug(
544
+ "warning",
545
+ "Invalid sqltype attempted: %s",
546
+ sanitize_user_input(str(sqltype)),
547
+ )
548
+ raise ProgrammingError(
549
+ driver_error=f"Invalid sqltype: {sqltype}",
550
+ ddbc_error=(
551
+ f"sqltype must be SQL_CHAR ({ConstantsDDBC.SQL_CHAR.value}), "
552
+ f"SQL_WCHAR ({ConstantsDDBC.SQL_WCHAR.value}), or "
553
+ f"SQL_WMETADATA ({SQL_WMETADATA})"
554
+ ),
555
+ )
556
+
557
+ # Set default encoding based on sqltype if not provided
558
+ if encoding is None:
559
+ if sqltype == ConstantsDDBC.SQL_CHAR.value:
560
+ encoding = "utf-8" # Default for SQL_CHAR in Python 3
561
+ else: # SQL_WCHAR or SQL_WMETADATA
562
+ encoding = "utf-16le" # Default for SQL_WCHAR in Python 3
563
+
564
+ # Validate encoding using cached validation for better performance
565
+ if not _validate_encoding(encoding):
566
+ logger.debug(
567
+ "warning",
568
+ "Invalid encoding attempted: %s",
569
+ sanitize_user_input(str(encoding)),
570
+ )
571
+ raise ProgrammingError(
572
+ driver_error=f"Unsupported encoding: {encoding}",
573
+ ddbc_error=f"The encoding '{encoding}' is not supported by Python",
574
+ )
575
+
576
+ # Normalize encoding to lowercase for consistency
577
+ encoding = encoding.lower()
578
+
579
+ # Set default ctype based on encoding if not provided
580
+ if ctype is None:
581
+ if encoding in UTF16_ENCODINGS:
582
+ ctype = ConstantsDDBC.SQL_WCHAR.value
583
+ else:
584
+ ctype = ConstantsDDBC.SQL_CHAR.value
585
+
586
+ # Validate ctype
587
+ valid_ctypes = [ConstantsDDBC.SQL_CHAR.value, ConstantsDDBC.SQL_WCHAR.value]
588
+ if ctype not in valid_ctypes:
589
+ logger.debug(
590
+ "warning",
591
+ "Invalid ctype attempted: %s",
592
+ sanitize_user_input(str(ctype)),
593
+ )
594
+ raise ProgrammingError(
595
+ driver_error=f"Invalid ctype: {ctype}",
596
+ ddbc_error=(
597
+ f"ctype must be SQL_CHAR ({ConstantsDDBC.SQL_CHAR.value}) or "
598
+ f"SQL_WCHAR ({ConstantsDDBC.SQL_WCHAR.value})"
599
+ ),
600
+ )
601
+
602
+ # Store the decoding settings for the specified sqltype
603
+ self._decoding_settings[sqltype] = {"encoding": encoding, "ctype": ctype}
604
+
605
+ # Log with sanitized values for security
606
+ sqltype_name = {
607
+ ConstantsDDBC.SQL_CHAR.value: "SQL_CHAR",
608
+ ConstantsDDBC.SQL_WCHAR.value: "SQL_WCHAR",
609
+ SQL_WMETADATA: "SQL_WMETADATA",
610
+ }.get(sqltype, str(sqltype))
611
+
612
+ logger.debug(
613
+ "info",
614
+ "Text decoding set for %s to %s with ctype %s",
615
+ sqltype_name,
616
+ sanitize_user_input(encoding),
617
+ sanitize_user_input(str(ctype)),
618
+ )
619
+
620
+ def getdecoding(self, sqltype: int) -> Dict[str, Union[str, int]]:
621
+ """
622
+ Gets the current text decoding settings for the specified SQL type.
623
+
624
+ Args:
625
+ sqltype (int): The SQL type to get settings for: SQL_CHAR, SQL_WCHAR, or SQL_WMETADATA.
626
+
627
+ Returns:
628
+ dict: A dictionary containing 'encoding' and 'ctype' keys for the specified sqltype.
629
+
630
+ Raises:
631
+ ProgrammingError: If the sqltype is invalid.
632
+ InterfaceError: If the connection is closed.
633
+
634
+ Example:
635
+ settings = cnxn.getdecoding(mssql_python.SQL_CHAR)
636
+ print(f"SQL_CHAR encoding: {settings['encoding']}")
637
+ print(f"SQL_CHAR ctype: {settings['ctype']}")
638
+ """
639
+ if self._closed:
640
+ raise InterfaceError(
641
+ driver_error="Connection is closed",
642
+ ddbc_error="Connection is closed",
643
+ )
644
+
645
+ # Validate sqltype
646
+ valid_sqltypes = [
647
+ ConstantsDDBC.SQL_CHAR.value,
648
+ ConstantsDDBC.SQL_WCHAR.value,
649
+ SQL_WMETADATA,
650
+ ]
651
+ if sqltype not in valid_sqltypes:
652
+ raise ProgrammingError(
653
+ driver_error=f"Invalid sqltype: {sqltype}",
654
+ ddbc_error=(
655
+ f"sqltype must be SQL_CHAR ({ConstantsDDBC.SQL_CHAR.value}), "
656
+ f"SQL_WCHAR ({ConstantsDDBC.SQL_WCHAR.value}), or "
657
+ f"SQL_WMETADATA ({SQL_WMETADATA})"
658
+ ),
659
+ )
660
+
661
+ return self._decoding_settings[sqltype].copy()
662
+
663
+ def set_attr(
664
+ self, attribute: int, value: Union[int, str, bytes, bytearray]
665
+ ) -> None:
666
+ """
667
+ Set a connection attribute.
668
+
669
+ This method sets a connection attribute using SQLSetConnectAttr.
670
+ It provides pyodbc-compatible functionality for configuring connection
671
+ behavior such as autocommit mode, transaction isolation level, and
672
+ connection timeouts.
673
+
674
+ Args:
675
+ attribute (int): The connection attribute to set. Should be one of the
676
+ SQL_ATTR_* constants (e.g., SQL_ATTR_AUTOCOMMIT,
677
+ SQL_ATTR_TXN_ISOLATION).
678
+ value: The value to set for the attribute. Can be an integer, string,
679
+ bytes, or bytearray depending on the attribute type.
680
+
681
+ Raises:
682
+ InterfaceError: If the connection is closed or attribute is invalid.
683
+ ProgrammingError: If the value type or range is invalid.
684
+ ProgrammingError: If the attribute cannot be set after connection.
685
+
686
+ Example:
687
+ >>> conn.set_attr(SQL_ATTR_TXN_ISOLATION, SQL_TXN_READ_COMMITTED)
688
+
689
+ Note:
690
+ Some attributes (like SQL_ATTR_LOGIN_TIMEOUT, SQL_ATTR_ODBC_CURSORS, and
691
+ SQL_ATTR_PACKET_SIZE) can only be set before connection establishment and
692
+ must be provided in the attrs_before parameter when creating the connection.
693
+ Attempting to set these attributes after connection will raise a ProgrammingError.
694
+ """
695
+ if self._closed:
696
+ raise InterfaceError(
697
+ "Cannot set attribute on closed connection", "Connection is closed"
698
+ )
699
+
700
+ # Use the integrated validation helper function with connection state
701
+ is_valid, error_message, sanitized_attr, sanitized_val = (
702
+ validate_attribute_value(attribute, value, is_connected=True)
703
+ )
704
+
705
+ if not is_valid:
706
+ # Use the already sanitized values for logging
707
+ logger.debug(
708
+ "warning",
709
+ f"Invalid attribute or value: {sanitized_attr}={sanitized_val}, {error_message}",
710
+ )
711
+ raise ProgrammingError(
712
+ driver_error=f"Invalid attribute or value: {error_message}",
713
+ ddbc_error=error_message,
714
+ )
715
+
716
+ # Log with sanitized values
717
+ logger.debug( f"Setting connection attribute: {sanitized_attr}={sanitized_val}")
718
+
719
+ try:
720
+ # Call the underlying C++ method
721
+ self._conn.set_attr(attribute, value)
722
+ logger.info( f"Connection attribute {sanitized_attr} set successfully")
723
+
724
+ except Exception as e:
725
+ error_msg = f"Failed to set connection attribute {sanitized_attr}: {str(e)}"
726
+
727
+ # Determine appropriate exception type based on error content
728
+ error_str = str(e).lower()
729
+ if (
730
+ "invalid" in error_str
731
+ or "unsupported" in error_str
732
+ or "cast" in error_str
733
+ ):
734
+ logger.error(error_msg)
735
+ raise InterfaceError(error_msg, str(e)) from e
736
+ logger.error(error_msg)
737
+ raise ProgrammingError(error_msg, str(e)) from e
738
+
739
+ @property
740
+ def searchescape(self) -> str:
741
+ """
742
+ The ODBC search pattern escape character, as returned by
743
+ SQLGetInfo(SQL_SEARCH_PATTERN_ESCAPE), used to escape special characters
744
+ such as '%' and '_' in LIKE clauses. These are driver specific.
745
+
746
+ Returns:
747
+ str: The search pattern escape character (usually '\' or another character)
748
+ """
749
+ if not hasattr(self, "_searchescape") or self._searchescape is None:
750
+ try:
751
+ escape_char = self.getinfo(
752
+ GetInfoConstants.SQL_SEARCH_PATTERN_ESCAPE.value
753
+ )
754
+ # Some drivers might return this as an integer memory address
755
+ # or other non-string format, so ensure we have a string
756
+ if not isinstance(escape_char, str):
757
+ # Default to backslash if not a string
758
+ escape_char = "\\"
759
+ self._searchescape = escape_char
760
+ except Exception as e:
761
+ # Log the exception for debugging, but do not expose sensitive info
762
+ logger.debug(
763
+ "warning",
764
+ "Failed to retrieve search escape character, using default '\\'. "
765
+ "Exception: %s",
766
+ type(e).__name__,
767
+ )
768
+ self._searchescape = "\\"
769
+ return self._searchescape
770
+
771
+ def cursor(self) -> Cursor:
772
+ """
773
+ Return a new Cursor object using the connection.
774
+
775
+ This method creates and returns a new cursor object that can be used to
776
+ execute SQL queries and fetch results. The cursor is associated with the
777
+ current connection and allows interaction with the database.
778
+
779
+ Returns:
780
+ Cursor: A new cursor object for executing SQL queries.
781
+
782
+ Raises:
783
+ DatabaseError: If there is an error while creating the cursor.
784
+ InterfaceError: If there is an error related to the database interface.
785
+ """
786
+ logger.debug('cursor: Creating new cursor - timeout=%d, total_cursors=%d',
787
+ self._timeout, len(self._cursors))
788
+ if self._closed:
789
+ logger.error('cursor: Cannot create cursor on closed connection')
790
+ # raise InterfaceError
791
+ raise InterfaceError(
792
+ driver_error="Cannot create cursor on closed connection",
793
+ ddbc_error="Cannot create cursor on closed connection",
794
+ )
795
+
796
+ cursor = Cursor(self, timeout=self._timeout)
797
+ self._cursors.add(cursor) # Track the cursor
798
+ logger.debug('cursor: Cursor created successfully - total_cursors=%d', len(self._cursors))
799
+ return cursor
800
+
801
+ def add_output_converter(self, sqltype: int, func: Callable[[Any], Any]) -> None:
802
+ """
803
+ Register an output converter function that will be called whenever a value
804
+ with the given SQL type is read from the database.
805
+
806
+ Thread-safe implementation that protects the converters dictionary with a lock.
807
+
808
+ ⚠️ WARNING: Registering an output converter will cause the supplied Python function
809
+ to be executed on every matching database value. Do not register converters from
810
+ untrusted sources, as this can result in arbitrary code execution and security
811
+ vulnerabilities. This API should never be exposed to untrusted or external input.
812
+
813
+ Args:
814
+ sqltype (int): The integer SQL type value to convert, which can be one of the
815
+ defined standard constants (e.g. SQL_VARCHAR) or a database-specific
816
+ value (e.g. -151 for the SQL Server 2008 geometry data type).
817
+ func (callable): The converter function which will be called with a single parameter,
818
+ the value, and should return the converted value. If the value is NULL
819
+ then the parameter passed to the function will be None, otherwise it
820
+ will be a bytes object.
821
+
822
+ Returns:
823
+ None
824
+ """
825
+ with self._converters_lock:
826
+ self._output_converters[sqltype] = func
827
+ # Pass to the underlying connection if native implementation supports it
828
+ if hasattr(self._conn, "add_output_converter"):
829
+ self._conn.add_output_converter(sqltype, func)
830
+ logger.info( f"Added output converter for SQL type {sqltype}")
831
+
832
+ def get_output_converter(
833
+ self, sqltype: Union[int, type]
834
+ ) -> Optional[Callable[[Any], Any]]:
835
+ """
836
+ Get the output converter function for the specified SQL type.
837
+
838
+ Thread-safe implementation that protects the converters dictionary with a lock.
839
+
840
+ Args:
841
+ sqltype (int or type): The SQL type value or Python type to get the converter for
842
+
843
+ Returns:
844
+ callable or None: The converter function or None if no converter is registered
845
+
846
+ Note:
847
+ ⚠️ The returned converter function will be executed on database values. Only use
848
+ converters from trusted sources.
849
+ """
850
+ with self._converters_lock:
851
+ return self._output_converters.get(sqltype)
852
+
853
+ def remove_output_converter(self, sqltype: Union[int, type]) -> None:
854
+ """
855
+ Remove the output converter function for the specified SQL type.
856
+
857
+ Thread-safe implementation that protects the converters dictionary with a lock.
858
+
859
+ Args:
860
+ sqltype (int or type): The SQL type value to remove the converter for
861
+
862
+ Returns:
863
+ None
864
+ """
865
+ with self._converters_lock:
866
+ if sqltype in self._output_converters:
867
+ del self._output_converters[sqltype]
868
+ # Pass to the underlying connection if native implementation supports it
869
+ if hasattr(self._conn, "remove_output_converter"):
870
+ self._conn.remove_output_converter(sqltype)
871
+ logger.info( f"Removed output converter for SQL type {sqltype}")
872
+
873
+ def clear_output_converters(self) -> None:
874
+ """
875
+ Remove all output converter functions.
876
+
877
+ Thread-safe implementation that protects the converters dictionary with a lock.
878
+
879
+ Returns:
880
+ None
881
+ """
882
+ with self._converters_lock:
883
+ self._output_converters.clear()
884
+ # Pass to the underlying connection if native implementation supports it
885
+ if hasattr(self._conn, "clear_output_converters"):
886
+ self._conn.clear_output_converters()
887
+ logger.info( "Cleared all output converters")
888
+
889
+ def execute(self, sql: str, *args: Any) -> Cursor:
890
+ """
891
+ Creates a new Cursor object, calls its execute method, and returns the new cursor.
892
+
893
+ This is a convenience method that is not part of the DB API. Since a new Cursor
894
+ is allocated by each call, this should not be used if more than one SQL statement
895
+ needs to be executed on the connection.
896
+
897
+ Note on cursor lifecycle management:
898
+ - Each call creates a new cursor that is tracked by the connection's internal WeakSet
899
+ - Cursors are automatically dereferenced/closed when they go out of scope
900
+ - For long-running applications or loops, explicitly call cursor.close() when done
901
+ to release resources immediately rather than waiting for garbage collection
902
+
903
+ Args:
904
+ sql (str): The SQL query to execute.
905
+ *args: Parameters to be passed to the query.
906
+
907
+ Returns:
908
+ Cursor: A new cursor with the executed query.
909
+
910
+ Raises:
911
+ DatabaseError: If there is an error executing the query.
912
+ InterfaceError: If the connection is closed.
913
+
914
+ Example:
915
+ # Automatic cleanup (cursor goes out of scope after the operation)
916
+ row = connection.execute("SELECT name FROM users WHERE id = ?", 123).fetchone()
917
+
918
+ # Manual cleanup for more explicit resource management
919
+ cursor = connection.execute("SELECT * FROM large_table")
920
+ try:
921
+ # Use cursor...
922
+ rows = cursor.fetchall()
923
+ finally:
924
+ cursor.close() # Explicitly release resources
925
+ """
926
+ cursor = self.cursor()
927
+ try:
928
+ # Add the cursor to our tracking set BEFORE execution
929
+ # This ensures it's tracked even if execution fails
930
+ self._cursors.add(cursor)
931
+
932
+ # Now execute the query
933
+ cursor.execute(sql, *args)
934
+ return cursor
935
+ except Exception:
936
+ # If execution fails, close the cursor to avoid leaking resources
937
+ cursor.close()
938
+ raise
939
+
940
+ def batch_execute(
941
+ self,
942
+ statements: List[str],
943
+ params: Optional[List[Union[None, Any, Tuple[Any, ...], List[Any]]]] = None,
944
+ reuse_cursor: Optional[Cursor] = None,
945
+ auto_close: bool = False,
946
+ ) -> Tuple[List[Union[List["Row"], int]], Cursor]:
947
+ """
948
+ Execute multiple SQL statements efficiently using a single cursor.
949
+
950
+ This method allows executing multiple SQL statements in sequence using a single
951
+ cursor, which is more efficient than creating a new cursor for each statement.
952
+
953
+ Args:
954
+ statements (list): List of SQL statements to execute
955
+ params (list, optional): List of parameter sets corresponding to statements.
956
+ Each item can be None, a single parameter, or a sequence of parameters.
957
+ If None, no parameters will be used for any statement.
958
+ reuse_cursor (Cursor, optional): Existing cursor to reuse instead of creating a new one.
959
+ If None, a new cursor will be created.
960
+ auto_close (bool): Whether to close the cursor after execution if a new one was created.
961
+ Defaults to False. Has no effect if reuse_cursor is provided.
962
+
963
+ Returns:
964
+ tuple: (results, cursor) where:
965
+ - results is a list of execution results, one for each statement
966
+ - cursor is the cursor used for execution (useful if you want to keep using it)
967
+
968
+ Raises:
969
+ TypeError: If statements is not a list or if params is provided but not a list
970
+ ValueError: If params is provided but has different length than statements
971
+ DatabaseError: If there is an error executing any of the statements
972
+ InterfaceError: If the connection is closed
973
+
974
+ Example:
975
+ # Execute multiple statements with a single cursor
976
+ results, _ = conn.batch_execute([
977
+ "INSERT INTO users VALUES (?, ?)",
978
+ "UPDATE stats SET count = count + 1",
979
+ "SELECT * FROM users"
980
+ ], [
981
+ (1, "user1"),
982
+ None,
983
+ None
984
+ ])
985
+
986
+ # Last result contains the SELECT results
987
+ for row in results[-1]:
988
+ print(row)
989
+
990
+ # Reuse an existing cursor
991
+ my_cursor = conn.cursor()
992
+ results, _ = conn.batch_execute([
993
+ "SELECT * FROM table1",
994
+ "SELECT * FROM table2"
995
+ ], reuse_cursor=my_cursor)
996
+
997
+ # Cursor remains open for further use
998
+ my_cursor.execute("SELECT * FROM table3")
999
+ """
1000
+ # Validate inputs
1001
+ if not isinstance(statements, list):
1002
+ raise TypeError("statements must be a list of SQL statements")
1003
+
1004
+ if params is not None:
1005
+ if not isinstance(params, list):
1006
+ raise TypeError("params must be a list of parameter sets")
1007
+ if len(params) != len(statements):
1008
+ raise ValueError(
1009
+ "params list must have the same length as statements list"
1010
+ )
1011
+ else:
1012
+ # Create a list of None values with the same length as statements
1013
+ params = [None] * len(statements)
1014
+
1015
+ # Determine which cursor to use
1016
+ is_new_cursor = reuse_cursor is None
1017
+ cursor = self.cursor() if is_new_cursor else reuse_cursor
1018
+
1019
+ # Execute statements and collect results
1020
+ results = []
1021
+ try:
1022
+ for i, (stmt, param) in enumerate(zip(statements, params)):
1023
+ try:
1024
+ # Execute the statement with parameters if provided
1025
+ if param is not None:
1026
+ cursor.execute(stmt, param)
1027
+ else:
1028
+ cursor.execute(stmt)
1029
+
1030
+ # For SELECT statements, fetch all rows
1031
+ # For other statements, get the row count
1032
+ if cursor.description is not None:
1033
+ # This is a SELECT statement or similar that returns rows
1034
+ results.append(cursor.fetchall())
1035
+ else:
1036
+ # This is an INSERT, UPDATE, DELETE or similar that doesn't return rows
1037
+ results.append(cursor.rowcount)
1038
+
1039
+ logger.debug( f"Executed batch statement {i+1}/{len(statements)}")
1040
+
1041
+ except Exception as e:
1042
+ # If a statement fails, include statement context in the error
1043
+ logger.debug(
1044
+ "error",
1045
+ f"Error executing statement {i+1}/{len(statements)}: {e}",
1046
+ )
1047
+ raise
1048
+
1049
+ except Exception:
1050
+ # If an error occurs and auto_close is True, close the cursor
1051
+ if auto_close:
1052
+ try:
1053
+ # Close the cursor regardless of whether it's reused or new
1054
+ cursor.close()
1055
+ logger.debug(
1056
+ "debug",
1057
+ "Automatically closed cursor after batch execution error",
1058
+ )
1059
+ except Exception as close_err:
1060
+ logger.debug(
1061
+ "warning",
1062
+ f"Error closing cursor after execution failure: {close_err}",
1063
+ )
1064
+ # Re-raise the original exception
1065
+ raise
1066
+
1067
+ # Close the cursor if requested and we created a new one
1068
+ if is_new_cursor and auto_close:
1069
+ cursor.close()
1070
+ logger.debug( "Automatically closed cursor after batch execution")
1071
+
1072
+ return results, cursor
1073
+
1074
+ def getinfo(self, info_type: int) -> Union[str, int, bool, None]:
1075
+ """
1076
+ Return general information about the driver and data source.
1077
+
1078
+ Args:
1079
+ info_type (int): The type of information to return. See the ODBC
1080
+ SQLGetInfo documentation for the supported values.
1081
+
1082
+ Returns:
1083
+ The requested information. The type of the returned value depends
1084
+ on the information requested. It will be a string, integer, or boolean.
1085
+
1086
+ Raises:
1087
+ DatabaseError: If there is an error retrieving the information.
1088
+ InterfaceError: If the connection is closed.
1089
+ """
1090
+ if self._closed:
1091
+ raise InterfaceError(
1092
+ driver_error="Cannot get info on closed connection",
1093
+ ddbc_error="Cannot get info on closed connection",
1094
+ )
1095
+
1096
+ # Check that info_type is an integer
1097
+ if not isinstance(info_type, int):
1098
+ raise ValueError(
1099
+ f"info_type must be an integer, got {type(info_type).__name__}"
1100
+ )
1101
+
1102
+ # Check for invalid info_type values
1103
+ if info_type < 0:
1104
+ logger.debug(
1105
+ "warning",
1106
+ f"Invalid info_type: {info_type}. Must be a positive integer.",
1107
+ )
1108
+ return None
1109
+
1110
+ # Get the raw result from the C++ layer
1111
+ try:
1112
+ raw_result = self._conn.get_info(info_type)
1113
+ except Exception as e: # pylint: disable=broad-exception-caught
1114
+ # Log the error and return None for invalid info types
1115
+ logger.warning( f"getinfo({info_type}) failed: {e}")
1116
+ return None
1117
+
1118
+ if raw_result is None:
1119
+ return None
1120
+
1121
+ # Check if the result is already a simple type
1122
+ if isinstance(raw_result, (str, int, bool)):
1123
+ return raw_result
1124
+
1125
+ # If it's a dictionary with data and metadata
1126
+ if isinstance(raw_result, dict) and "data" in raw_result:
1127
+ # Extract data and metadata from the raw result
1128
+ data = raw_result["data"]
1129
+ length = raw_result["length"]
1130
+
1131
+ # Debug logging to understand the issue better
1132
+ logger.debug(
1133
+ "debug",
1134
+ f"getinfo: info_type={info_type}, length={length}, data_type={type(data)}",
1135
+ )
1136
+
1137
+ # Define constants for different return types
1138
+ # String types - these return strings in pyodbc
1139
+ string_type_constants = {
1140
+ GetInfoConstants.SQL_DATA_SOURCE_NAME.value,
1141
+ GetInfoConstants.SQL_DRIVER_NAME.value,
1142
+ GetInfoConstants.SQL_DRIVER_VER.value,
1143
+ GetInfoConstants.SQL_SERVER_NAME.value,
1144
+ GetInfoConstants.SQL_USER_NAME.value,
1145
+ GetInfoConstants.SQL_DRIVER_ODBC_VER.value,
1146
+ GetInfoConstants.SQL_IDENTIFIER_QUOTE_CHAR.value,
1147
+ GetInfoConstants.SQL_CATALOG_NAME_SEPARATOR.value,
1148
+ GetInfoConstants.SQL_CATALOG_TERM.value,
1149
+ GetInfoConstants.SQL_SCHEMA_TERM.value,
1150
+ GetInfoConstants.SQL_TABLE_TERM.value,
1151
+ GetInfoConstants.SQL_KEYWORDS.value,
1152
+ GetInfoConstants.SQL_PROCEDURE_TERM.value,
1153
+ GetInfoConstants.SQL_SPECIAL_CHARACTERS.value,
1154
+ GetInfoConstants.SQL_SEARCH_PATTERN_ESCAPE.value,
1155
+ }
1156
+
1157
+ # Boolean 'Y'/'N' types
1158
+ yn_type_constants = {
1159
+ GetInfoConstants.SQL_ACCESSIBLE_PROCEDURES.value,
1160
+ GetInfoConstants.SQL_ACCESSIBLE_TABLES.value,
1161
+ GetInfoConstants.SQL_DATA_SOURCE_READ_ONLY.value,
1162
+ GetInfoConstants.SQL_EXPRESSIONS_IN_ORDERBY.value,
1163
+ GetInfoConstants.SQL_LIKE_ESCAPE_CLAUSE.value,
1164
+ GetInfoConstants.SQL_MULTIPLE_ACTIVE_TXN.value,
1165
+ GetInfoConstants.SQL_NEED_LONG_DATA_LEN.value,
1166
+ GetInfoConstants.SQL_PROCEDURES.value,
1167
+ }
1168
+
1169
+ # Numeric type constants that return integers
1170
+ numeric_type_constants = {
1171
+ GetInfoConstants.SQL_MAX_COLUMN_NAME_LEN.value,
1172
+ GetInfoConstants.SQL_MAX_TABLE_NAME_LEN.value,
1173
+ GetInfoConstants.SQL_MAX_SCHEMA_NAME_LEN.value,
1174
+ GetInfoConstants.SQL_MAX_CATALOG_NAME_LEN.value,
1175
+ GetInfoConstants.SQL_MAX_IDENTIFIER_LEN.value,
1176
+ GetInfoConstants.SQL_MAX_STATEMENT_LEN.value,
1177
+ GetInfoConstants.SQL_MAX_DRIVER_CONNECTIONS.value,
1178
+ GetInfoConstants.SQL_NUMERIC_FUNCTIONS.value,
1179
+ GetInfoConstants.SQL_STRING_FUNCTIONS.value,
1180
+ GetInfoConstants.SQL_DATETIME_FUNCTIONS.value,
1181
+ GetInfoConstants.SQL_TXN_CAPABLE.value,
1182
+ GetInfoConstants.SQL_DEFAULT_TXN_ISOLATION.value,
1183
+ GetInfoConstants.SQL_CURSOR_COMMIT_BEHAVIOR.value,
1184
+ }
1185
+
1186
+ # Determine the type of information we're dealing with
1187
+ is_string_type = (
1188
+ info_type > INFO_TYPE_STRING_THRESHOLD
1189
+ or info_type in string_type_constants
1190
+ )
1191
+ is_yn_type = info_type in yn_type_constants
1192
+ is_numeric_type = info_type in numeric_type_constants
1193
+
1194
+ # Process the data based on type
1195
+ if is_string_type:
1196
+ # For string data, ensure we properly handle the byte array
1197
+ if isinstance(data, bytes):
1198
+ # Make sure we use the correct amount of data based on length
1199
+ actual_data = data[:length]
1200
+
1201
+ # Now decode the string data
1202
+ try:
1203
+ return actual_data.decode("utf-8").rstrip("\0")
1204
+ except UnicodeDecodeError:
1205
+ try:
1206
+ return actual_data.decode("latin1").rstrip("\0")
1207
+ except Exception as e:
1208
+ logger.debug(
1209
+ "error",
1210
+ "Failed to decode string in getinfo: %s. "
1211
+ "Returning None to avoid silent corruption.",
1212
+ e,
1213
+ )
1214
+ # Explicitly return None to signal decoding failure
1215
+ return None
1216
+ else:
1217
+ # If it's not bytes, return as is
1218
+ return data
1219
+ elif is_yn_type:
1220
+ # For Y/N types, pyodbc returns a string 'Y' or 'N'
1221
+ if isinstance(data, bytes) and length >= 1:
1222
+ byte_val = data[0]
1223
+ if byte_val in (b"Y"[0], b"y"[0], 1):
1224
+ return "Y"
1225
+ return "N"
1226
+ # If it's not a byte or we can't determine, default to 'N'
1227
+ return "N"
1228
+ elif is_numeric_type:
1229
+ # Handle numeric types based on length
1230
+ if isinstance(data, bytes):
1231
+ # Map byte length → signed int size
1232
+ int_sizes = {
1233
+ 1: lambda d: int(d[0]),
1234
+ 2: lambda d: int.from_bytes(d[:2], "little", signed=True),
1235
+ 4: lambda d: int.from_bytes(d[:4], "little", signed=True),
1236
+ 8: lambda d: int.from_bytes(d[:8], "little", signed=True),
1237
+ }
1238
+
1239
+ # Direct numeric conversion if supported length
1240
+ if length in int_sizes:
1241
+ result = int_sizes[length](data)
1242
+ return int(result)
1243
+
1244
+ # Helper: check if all chars are digits
1245
+ def is_digit_bytes(b: bytes) -> bool:
1246
+ return all(c in b"0123456789" for c in b)
1247
+
1248
+ # Helper: check if bytes are ASCII-printable or NUL padded
1249
+ def is_printable_bytes(b: bytes) -> bool:
1250
+ return all(32 <= c <= 126 or c == 0 for c in b)
1251
+
1252
+ chunk = data[:length]
1253
+
1254
+ # Try interpret as integer string
1255
+ if is_digit_bytes(chunk):
1256
+ return int(chunk)
1257
+
1258
+ # Try decode as ASCII/UTF-8 string
1259
+ if is_printable_bytes(chunk):
1260
+ str_val = chunk.decode("utf-8", errors="replace").rstrip("\0")
1261
+ return int(str_val) if str_val.isdigit() else str_val
1262
+
1263
+ # For 16-bit values that might be returned for max lengths
1264
+ if length == 2:
1265
+ return int.from_bytes(data[:2], "little", signed=True)
1266
+
1267
+ # For 32-bit values (common for bitwise flags)
1268
+ if length == 4:
1269
+ return int.from_bytes(data[:4], "little", signed=True)
1270
+
1271
+ # Fallback: try to convert to int if possible
1272
+ try:
1273
+ if length <= 8:
1274
+ return int.from_bytes(data[:length], "little", signed=True)
1275
+ except Exception:
1276
+ pass
1277
+
1278
+ # Last resort: return as integer if all else fails
1279
+ try:
1280
+ return int.from_bytes(
1281
+ data[: min(length, 8)], "little", signed=True
1282
+ )
1283
+ except Exception:
1284
+ return 0
1285
+ elif isinstance(data, (int, float)):
1286
+ # Already numeric
1287
+ return int(data)
1288
+ else:
1289
+ # Try to convert to int if it's a string
1290
+ try:
1291
+ if isinstance(data, str) and data.isdigit():
1292
+ return int(data)
1293
+ except Exception:
1294
+ pass
1295
+
1296
+ # Return as is if we can't convert
1297
+ return data
1298
+
1299
+ # For other types, try to determine the most appropriate type
1300
+ if isinstance(data, bytes):
1301
+ # Try to convert to string first
1302
+ try:
1303
+ return data[:length].decode("utf-8").rstrip("\0")
1304
+ except UnicodeDecodeError:
1305
+ pass
1306
+
1307
+ # Try to convert to int for short binary data
1308
+ try:
1309
+ if length <= 8:
1310
+ return int.from_bytes(data[:length], "little", signed=True)
1311
+ except Exception: # pylint: disable=broad-exception-caught
1312
+ pass
1313
+
1314
+ # Return as is if we can't determine
1315
+ return data
1316
+
1317
+ return data
1318
+
1319
+ return raw_result # Return as-is
1320
+
1321
+ def commit(self) -> None:
1322
+ """
1323
+ Commit the current transaction.
1324
+
1325
+ This method commits the current transaction to the database, making all
1326
+ changes made during the transaction permanent. It should be called after
1327
+ executing a series of SQL statements that modify the database to ensure
1328
+ that the changes are saved.
1329
+
1330
+ Raises:
1331
+ InterfaceError: If the connection is closed.
1332
+ DatabaseError: If there is an error while committing the transaction.
1333
+ """
1334
+ # Check if connection is closed
1335
+ if self._closed or self._conn is None:
1336
+ raise InterfaceError(
1337
+ driver_error="Cannot commit on a closed connection",
1338
+ ddbc_error="Cannot commit on a closed connection",
1339
+ )
1340
+
1341
+ # Commit the current transaction
1342
+ self._conn.commit()
1343
+ logger.info( "Transaction committed successfully.")
1344
+
1345
+ def rollback(self) -> None:
1346
+ """
1347
+ Roll back the current transaction.
1348
+
1349
+ This method rolls back the current transaction, undoing all changes made
1350
+ during the transaction. It should be called if an error occurs during the
1351
+ transaction or if the changes should not be saved.
1352
+
1353
+ Raises:
1354
+ InterfaceError: If the connection is closed.
1355
+ DatabaseError: If there is an error while rolling back the transaction.
1356
+ """
1357
+ # Check if connection is closed
1358
+ if self._closed or self._conn is None:
1359
+ raise InterfaceError(
1360
+ driver_error="Cannot rollback on a closed connection",
1361
+ ddbc_error="Cannot rollback on a closed connection",
1362
+ )
1363
+
1364
+ # Roll back the current transaction
1365
+ self._conn.rollback()
1366
+ logger.info( "Transaction rolled back successfully.")
1367
+
1368
+ def close(self) -> None:
1369
+ """
1370
+ Close the connection now (rather than whenever .__del__() is called).
1371
+
1372
+ This method closes the connection to the database, releasing any resources
1373
+ associated with it. After calling this method, the connection object should
1374
+ not be used for any further operations. The same applies to all cursor objects
1375
+ trying to use the connection. Note that closing a connection without committing
1376
+ the changes first will cause an implicit rollback to be performed.
1377
+
1378
+ Raises:
1379
+ DatabaseError: If there is an error while closing the connection.
1380
+ """
1381
+ # Close the connection
1382
+ if self._closed:
1383
+ return
1384
+
1385
+ # Close all cursors first, but don't let one failure stop the others
1386
+ if hasattr(self, "_cursors"):
1387
+ # Convert to list to avoid modification during iteration
1388
+ cursors_to_close = list(self._cursors)
1389
+ close_errors = []
1390
+
1391
+ for cursor in cursors_to_close:
1392
+ try:
1393
+ if not cursor.closed:
1394
+ cursor.close()
1395
+ except Exception as e: # pylint: disable=broad-exception-caught
1396
+ # Collect errors but continue closing other cursors
1397
+ close_errors.append(f"Error closing cursor: {e}")
1398
+ logger.warning( f"Error closing cursor: {e}")
1399
+
1400
+ # If there were errors closing cursors, log them but continue
1401
+ if close_errors:
1402
+ logger.debug(
1403
+ "warning",
1404
+ "Encountered %d errors while closing cursors",
1405
+ len(close_errors),
1406
+ )
1407
+
1408
+ # Clear the cursor set explicitly to release any internal
1409
+ # references
1410
+ self._cursors.clear()
1411
+
1412
+ # Close the connection even if cursor cleanup had issues
1413
+ try:
1414
+ if self._conn:
1415
+ if not self.autocommit:
1416
+ # If autocommit is disabled, rollback any uncommitted changes
1417
+ # This is important to ensure no partial transactions remain
1418
+ # For autocommit True, this is not necessary as each statement is
1419
+ # committed immediately
1420
+ logger.debug("Rolling back uncommitted changes before closing connection.")
1421
+ self._conn.rollback()
1422
+ # TODO: Check potential race conditions in case of multithreaded scenarios
1423
+ # Close the connection
1424
+ self._conn.close()
1425
+ self._conn = None
1426
+ except Exception as e:
1427
+ logger.error( f"Error closing database connection: {e}")
1428
+ # Re-raise the connection close error as it's more critical
1429
+ raise
1430
+ finally:
1431
+ # Always mark as closed, even if there were errors
1432
+ self._closed = True
1433
+
1434
+ logger.info( "Connection closed successfully.")
1435
+
1436
+ def _remove_cursor(self, cursor: Cursor) -> None:
1437
+ """
1438
+ Remove a cursor from the connection's tracking.
1439
+
1440
+ This method is called when a cursor is closed to ensure proper cleanup.
1441
+
1442
+ Args:
1443
+ cursor: The cursor to remove from tracking.
1444
+ """
1445
+ if hasattr(self, "_cursors"):
1446
+ try:
1447
+ self._cursors.discard(cursor)
1448
+ except Exception:
1449
+ pass # Ignore errors during cleanup
1450
+
1451
+ def __enter__(self) -> "Connection":
1452
+ """
1453
+ Enter the context manager.
1454
+
1455
+ This method enables the Connection to be used with the 'with' statement.
1456
+ When entering the context, it simply returns the connection object itself.
1457
+
1458
+ Returns:
1459
+ Connection: The connection object itself.
1460
+
1461
+ Example:
1462
+ with connect(connection_string) as conn:
1463
+ cursor = conn.cursor()
1464
+ cursor.execute("INSERT INTO table VALUES (?)", [value])
1465
+ # Transaction will be committed automatically when exiting
1466
+ """
1467
+ logger.info( "Entering connection context manager.")
1468
+ return self
1469
+
1470
+ def __exit__(self, *args: Any) -> None:
1471
+ """
1472
+ Exit the context manager.
1473
+
1474
+ Closes the connection when exiting the context, ensuring proper
1475
+ resource cleanup. This follows the modern standard used by most
1476
+ database libraries.
1477
+ """
1478
+ if not self._closed:
1479
+ self.close()
1480
+
1481
+ def __del__(self) -> None:
1482
+ """
1483
+ Destructor to ensure the connection is closed when the connection object
1484
+ is no longer needed.
1485
+ This is a safety net to ensure resources are cleaned up
1486
+ even if close() was not called explicitly.
1487
+ """
1488
+ if "_closed" not in self.__dict__ or not self._closed:
1489
+ try:
1490
+ self.close()
1491
+ except Exception as e:
1492
+ # Dont raise exceptions from __del__ to avoid issues during garbage collection
1493
+ logger.warning( f"Error during connection cleanup: {e}")