From 8f691619b99a360db15a179a2e021d775568999f Mon Sep 17 00:00:00 2001 From: Jahnvi Thakkar Date: Wed, 20 Aug 2025 14:42:52 +0530 Subject: [PATCH 1/6] FEAT: Adding lowercase for global variable --- mssql_python/__init__.py | 26 ++++++++++---- mssql_python/cursor.py | 66 +++++++++++++++++++++-------------- mssql_python/row.py | 31 +++++++++++++---- tests/test_001_globals.py | 8 ++++- tests/test_004_cursor.py | 72 ++++++++++++++++++++++++++++++++++++++- 5 files changed, 163 insertions(+), 40 deletions(-) diff --git a/mssql_python/__init__.py b/mssql_python/__init__.py index 6bf957779..8f8635964 100644 --- a/mssql_python/__init__.py +++ b/mssql_python/__init__.py @@ -6,6 +6,26 @@ # Exceptions # https://www.python.org/dev/peps/pep-0249/#exceptions + +# GLOBALS +# Read-Only +apilevel = "2.0" +paramstyle = "qmark" +threadsafety = 1 + +class Settings: + def __init__(self): + self.lowercase = False + +# Create a global instance +_settings = Settings() + +def get_settings(): + return _settings + +lowercase = _settings.lowercase # Default is False + +# Import necessary modules from .exceptions import ( Warning, Error, @@ -47,12 +67,6 @@ # Constants from .constants import ConstantsDDBC -# GLOBALS -# Read-Only -apilevel = "2.0" -paramstyle = "qmark" -threadsafety = 1 - from .pooling import PoolingManager def pooling(max_size=100, idle_timeout=600, enabled=True): # """ diff --git a/mssql_python/cursor.py b/mssql_python/cursor.py index ed1bb70dc..912cb4a8c 100644 --- a/mssql_python/cursor.py +++ b/mssql_python/cursor.py @@ -17,7 +17,8 @@ from mssql_python.helpers import check_error, log from mssql_python import ddbc_bindings from mssql_python.exceptions import InterfaceError -from .row import Row +from mssql_python.row import Row +from mssql_python import get_settings class Cursor: @@ -73,6 +74,8 @@ def __init__(self, connection) -> None: # Is a list instead of a bool coz bools in Python are immutable. # Hence, we can't pass around bools by reference & modify them. # Therefore, it must be a list with exactly one bool element. + + self.lowercase = get_settings().lowercase def _is_unicode_string(self, param): """ @@ -480,26 +483,32 @@ def _create_parameter_types_list(self, parameter, param_info, parameters_list, i paraminfo.decimalDigits = decimal_digits return paraminfo - def _initialize_description(self): - """ - Initialize the description attribute using SQLDescribeCol. - """ - col_metadata = [] - ret = ddbc_bindings.DDBCSQLDescribeCol(self.hstmt, col_metadata) - check_error(ddbc_sql_const.SQL_HANDLE_STMT.value, self.hstmt, ret) - - self.description = [ - ( - col["ColumnName"], - self._map_data_type(col["DataType"]), - None, - col["ColumnSize"], - col["ColumnSize"], - col["DecimalDigits"], - col["Nullable"] == ddbc_sql_const.SQL_NULLABLE.value, - ) - for col in col_metadata - ] + def _initialize_description(self, column_metadata=None): + """Initialize the description attribute from column metadata.""" + if not column_metadata: + self.description = None + return + import mssql_python + + description = [] + for i, col in enumerate(column_metadata): + # Get column name - lowercase it if the lowercase flag is set + column_name = col["ColumnName"] + + if mssql_python.lowercase: + column_name = column_name.lower() + + # Add to description tuple (7 elements as per PEP-249) + description.append(( + column_name, # name + self._map_data_type(col["DataType"]), # type_code + None, # display_size + col["ColumnSize"], # internal_size + col["ColumnSize"], # precision - should match ColumnSize + col["DecimalDigits"], # scale + col["Nullable"] == ddbc_sql_const.SQL_NULLABLE.value, # null_ok + )) + self.description = description def _map_data_type(self, sql_type): """ @@ -611,7 +620,14 @@ def execute( self.rowcount = ddbc_bindings.DDBCSQLRowCount(self.hstmt) # Initialize description after execution - self._initialize_description() + # After successful execution, initialize description if there are results + column_metadata = [] + try: + ddbc_bindings.DDBCSQLDescribeCol(self.hstmt, column_metadata) + self._initialize_description(column_metadata) + except Exception as e: + # If describe fails, it's likely there are no results (e.g., for INSERT) + self.description = None @staticmethod def _select_best_sample_value(column): @@ -727,7 +743,7 @@ def fetchone(self) -> Union[None, Row]: return None # Create and return a Row object - return Row(row_data, self.description) + return Row(self, self.description, row_data) def fetchmany(self, size: int = None) -> List[Row]: """ @@ -752,7 +768,7 @@ def fetchmany(self, size: int = None) -> List[Row]: ret = ddbc_bindings.DDBCSQLFetchMany(self.hstmt, rows_data, size) # Convert raw data to Row objects - return [Row(row_data, self.description) for row_data in rows_data] + return [Row(self, self.description, row_data) for row_data in rows_data] def fetchall(self) -> List[Row]: """ @@ -768,7 +784,7 @@ def fetchall(self) -> List[Row]: ret = ddbc_bindings.DDBCSQLFetchAll(self.hstmt, rows_data) # Convert raw data to Row objects - return [Row(row_data, self.description) for row_data in rows_data] + return [Row(self, self.description, row_data) for row_data in rows_data] def nextset(self) -> Union[bool, None]: """ diff --git a/mssql_python/row.py b/mssql_python/row.py index 2c88412de..0b1fd33ea 100644 --- a/mssql_python/row.py +++ b/mssql_python/row.py @@ -9,14 +9,17 @@ class Row: print(row.column_name) # Access by column name """ - def __init__(self, values, cursor_description): + def __init__(self, cursor, description, values, column_map=None): """ Initialize a Row object with values and cursor description. Args: + cursor: The cursor object + description: The cursor description containing column metadata values: List of values for this row - cursor_description: The cursor description containing column metadata + column_map: Optional pre-built column map (for optimization) """ + self._cursor = cursor self._values = values # TODO: ADO task - Optimize memory usage by sharing column map across rows @@ -26,10 +29,14 @@ def __init__(self, values, cursor_description): # 3. Remove cursor_description from Row objects entirely # Create mapping of column names to indices - self._column_map = {} - for i, desc in enumerate(cursor_description): - if desc and desc[0]: # Ensure column name exists - self._column_map[desc[0]] = i + # If column_map is not provided, build it from description + if column_map is None: + column_map = {} + for i, col_desc in enumerate(description): + col_name = col_desc[0] # Name is first item in description tuple + column_map[col_name] = i + + self._column_map = column_map def __getitem__(self, index): """Allow accessing by numeric index: row[0]""" @@ -37,9 +44,19 @@ def __getitem__(self, index): def __getattr__(self, name): """Allow accessing by column name as attribute: row.column_name""" + # Handle lowercase attribute access - if lowercase is enabled, + # try to match attribute names case-insensitively if name in self._column_map: return self._values[self._column_map[name]] - raise AttributeError(f"'{self.__class__.__name__}' object has no attribute '{name}'") + + # If lowercase is enabled on the cursor, try case-insensitive lookup + if hasattr(self._cursor, 'lowercase') and self._cursor.lowercase: + name_lower = name.lower() + for col_name in self._column_map: + if col_name.lower() == name_lower: + return self._values[self._column_map[col_name]] + + raise AttributeError(f"Row has no attribute '{name}'") def __eq__(self, other): """ diff --git a/tests/test_001_globals.py b/tests/test_001_globals.py index f41a9a14f..fbee7ec5e 100644 --- a/tests/test_001_globals.py +++ b/tests/test_001_globals.py @@ -4,12 +4,13 @@ - test_apilevel: Check if apilevel has the expected value. - test_threadsafety: Check if threadsafety has the expected value. - test_paramstyle: Check if paramstyle has the expected value. +- test_lowercase: Check if lowercase has the expected value. """ import pytest # Import global variables from the repository -from mssql_python import apilevel, threadsafety, paramstyle +from mssql_python import apilevel, threadsafety, paramstyle, lowercase def test_apilevel(): # Check if apilevel has the expected value @@ -22,3 +23,8 @@ def test_threadsafety(): def test_paramstyle(): # Check if paramstyle has the expected value assert paramstyle == "qmark", "paramstyle should be 'qmark'" + +def test_lowercase(): + # Check if lowercase has the expected default value + assert lowercase is False, "lowercase should default to False" + diff --git a/tests/test_004_cursor.py b/tests/test_004_cursor.py index 6a8c84281..728b27e23 100644 --- a/tests/test_004_cursor.py +++ b/tests/test_004_cursor.py @@ -12,6 +12,7 @@ from datetime import datetime, date, time import decimal from mssql_python import Connection +import mssql_python # Setup test table TEST_TABLE = """ @@ -1313,6 +1314,76 @@ def test_row_column_mapping(cursor, db_connection): cursor.execute("DROP TABLE #pytest_row_test") db_connection.commit() +def test_lowercase_attribute(cursor, db_connection): + """Test that the lowercase attribute properly converts column names to lowercase""" + + # Store original value to restore after test + original_lowercase = mssql_python.lowercase + drop_cursor = None + + try: + # Create a test table with mixed-case column names + cursor.execute(""" + CREATE TABLE #pytest_lowercase_test ( + ID INT PRIMARY KEY, + UserName VARCHAR(50), + EMAIL_ADDRESS VARCHAR(100), + PhoneNumber VARCHAR(20) + ) + """) + db_connection.commit() + + # Insert test data + cursor.execute(""" + INSERT INTO #pytest_lowercase_test (ID, UserName, EMAIL_ADDRESS, PhoneNumber) + VALUES (1, 'JohnDoe', 'john@example.com', '555-1234') + """) + db_connection.commit() + + # First test with lowercase=False (default) + mssql_python.lowercase = False + cursor1 = db_connection.cursor() + cursor1.execute("SELECT * FROM #pytest_lowercase_test") + + # Description column names should preserve original case + column_names1 = [desc[0] for desc in cursor1.description] + assert "ID" in column_names1, "Column 'ID' should be present with original case" + assert "UserName" in column_names1, "Column 'UserName' should be present with original case" + + # Make sure to consume all results and close the cursor + cursor1.fetchall() + cursor1.close() + + # Now test with lowercase=True + mssql_python.lowercase = True + cursor2 = db_connection.cursor() + cursor2.execute("SELECT * FROM #pytest_lowercase_test") + + # Description column names should be lowercase + column_names2 = [desc[0] for desc in cursor2.description] + assert "id" in column_names2, "Column names should be lowercase when lowercase=True" + assert "username" in column_names2, "Column names should be lowercase when lowercase=True" + + # Make sure to consume all results and close the cursor + cursor2.fetchall() + cursor2.close() + + # Create a fresh cursor for cleanup + drop_cursor = db_connection.cursor() + + finally: + # Restore original value + mssql_python.lowercase = original_lowercase + + try: + # Use a separate cursor for cleanup + if drop_cursor: + drop_cursor.execute("DROP TABLE IF EXISTS #pytest_lowercase_test") + db_connection.commit() + drop_cursor.close() + except Exception as e: + print(f"Warning: Failed to drop test table: {e}") + def test_close(db_connection): """Test closing the cursor""" try: @@ -1323,4 +1394,3 @@ def test_close(db_connection): pytest.fail(f"Cursor close test failed: {e}") finally: cursor = db_connection.cursor() - \ No newline at end of file From ee871ae315f400243e8870948e216551801ac49a Mon Sep 17 00:00:00 2001 From: Jahnvi Thakkar Date: Thu, 21 Aug 2025 12:16:12 +0530 Subject: [PATCH 2/6] FEAT: Adding getDecimalSeperator and setDecimalSeperator as global functions --- mssql_python/__init__.py | 38 +++++- mssql_python/pybind/ddbc_bindings.cpp | 44 +++++-- mssql_python/pybind/ddbc_bindings.h | 6 + mssql_python/row.py | 20 ++- tests/test_001_globals.py | 28 ++++- tests/test_004_cursor.py | 172 ++++++++++++++++++++++++++ 6 files changed, 294 insertions(+), 14 deletions(-) diff --git a/mssql_python/__init__.py b/mssql_python/__init__.py index 8f8635964..ec0f3b40a 100644 --- a/mssql_python/__init__.py +++ b/mssql_python/__init__.py @@ -16,8 +16,9 @@ class Settings: def __init__(self): self.lowercase = False + self.decimal_separator = "." -# Create a global instance +# Global settings instance _settings = Settings() def get_settings(): @@ -25,6 +26,40 @@ def get_settings(): lowercase = _settings.lowercase # Default is False +# Set the initial decimal separator in C++ +from .ddbc_bindings import DDBCSetDecimalSeparator +DDBCSetDecimalSeparator(_settings.decimal_separator) + +# New functions for decimal separator control +def setDecimalSeparator(separator): + """ + Sets the decimal separator character used when parsing NUMERIC/DECIMAL values + from the database, e.g. the "." in "1,234.56". + + The default is "." (period). This function overrides the default. + + Args: + separator (str): The character to use as decimal separator + """ + if not isinstance(separator, str) or len(separator) != 1: + raise ValueError("Decimal separator must be a single character string") + + _settings.decimal_separator = separator + + # Update the C++ side + from .ddbc_bindings import DDBCSetDecimalSeparator + DDBCSetDecimalSeparator(separator) + +def getDecimalSeparator(): + """ + Returns the decimal separator character used when parsing NUMERIC/DECIMAL values + from the database. + + Returns: + str: The current decimal separator character + """ + return _settings.decimal_separator + # Import necessary modules from .exceptions import ( Warning, @@ -85,4 +120,3 @@ def pooling(max_size=100, idle_timeout=600, enabled=True): PoolingManager.disable() else: PoolingManager.enable(max_size, idle_timeout) - \ No newline at end of file diff --git a/mssql_python/pybind/ddbc_bindings.cpp b/mssql_python/pybind/ddbc_bindings.cpp index 1b37b8f0f..b5588a25d 100644 --- a/mssql_python/pybind/ddbc_bindings.cpp +++ b/mssql_python/pybind/ddbc_bindings.cpp @@ -1600,12 +1600,17 @@ SQLRETURN SQLGetData_wrap(SqlHandlePtr StatementHandle, SQLUSMALLINT colCount, p ret = SQLGetData_ptr(hStmt, i, SQL_C_CHAR, numericStr, sizeof(numericStr), &indicator); if (SQL_SUCCEEDED(ret)) { - try{ - // Convert numericStr to py::decimal.Decimal and append to row - row.append(py::module_::import("decimal").attr("Decimal")( - std::string(reinterpret_cast(numericStr), indicator))); + try { + // Use the original string with period for Python's Decimal constructor + std::string numStr(reinterpret_cast(numericStr), indicator); + + // Create Python Decimal object + py::object decimalObj = py::module_::import("decimal").attr("Decimal")(numStr); + + // Add to row + row.append(decimalObj); } catch (const py::error_already_set& e) { - // If the conversion fails, append None + // If conversion fails, append None LOG("Error converting to decimal: {}", e.what()); row.append(py::none()); } @@ -2085,11 +2090,20 @@ SQLRETURN FetchBatchData(SQLHSTMT hStmt, ColumnBuffers& buffers, py::list& colum case SQL_DECIMAL: case SQL_NUMERIC: { try { - // Convert numericStr to py::decimal.Decimal and append to row - row.append(py::module_::import("decimal").attr("Decimal")(std::string( - reinterpret_cast( - &buffers.charBuffers[col - 1][i * MAX_DIGITS_IN_NUMERIC]), - buffers.indicators[col - 1][i]))); + // Convert the string to use the current decimal separator + std::string numStr(reinterpret_cast( + &buffers.charBuffers[col - 1][i * MAX_DIGITS_IN_NUMERIC]), + buffers.indicators[col - 1][i]); + if (g_decimalSeparator != ".") { + // Replace the driver's decimal point with our configured separator + size_t pos = numStr.find('.'); + if (pos != std::string::npos) { + numStr.replace(pos, 1, g_decimalSeparator); + } + } + + // Convert to Python decimal + row.append(py::module_::import("decimal").attr("Decimal")(numStr)); } catch (const py::error_already_set& e) { // Handle the exception, e.g., log the error and append py::none() LOG("Error converting to decimal: {}", e.what()); @@ -2480,6 +2494,14 @@ void enable_pooling(int maxSize, int idleTimeout) { }); } +// Global decimal separator setting with default value +std::string g_decimalSeparator = "."; + +void DDBCSetDecimalSeparator(const std::string& separator) { + LOG("Setting decimal separator to: {}", separator); + g_decimalSeparator = separator; +} + // Architecture-specific defines #ifndef ARCHITECTURE #define ARCHITECTURE "win64" // Default to win64 if not defined during compilation @@ -2553,6 +2575,8 @@ PYBIND11_MODULE(ddbc_bindings, m) { m.def("DDBCSQLFetchAll", &FetchAll_wrap, "Fetch all rows from the result set"); m.def("DDBCSQLFreeHandle", &SQLFreeHandle_wrap, "Free a handle"); m.def("DDBCSQLCheckError", &SQLCheckError_Wrap, "Check for driver errors"); + m.def("DDBCSetDecimalSeparator", &DDBCSetDecimalSeparator, "Set the decimal separator character"); + // Add a version attribute m.attr("__version__") = "1.0.0"; diff --git a/mssql_python/pybind/ddbc_bindings.h b/mssql_python/pybind/ddbc_bindings.h index 22bc524bd..d142276c6 100644 --- a/mssql_python/pybind/ddbc_bindings.h +++ b/mssql_python/pybind/ddbc_bindings.h @@ -271,3 +271,9 @@ inline std::wstring Utf8ToWString(const std::string& str) { return converter.from_bytes(str); #endif } + +// Global decimal separator setting +extern std::string g_decimalSeparator; + +// Function to set the decimal separator +void DDBCSetDecimalSeparator(const std::string& separator); diff --git a/mssql_python/row.py b/mssql_python/row.py index 0b1fd33ea..1f54e8c8c 100644 --- a/mssql_python/row.py +++ b/mssql_python/row.py @@ -79,7 +79,25 @@ def __iter__(self): def __str__(self): """Return string representation of the row""" - return str(tuple(self._values)) + from decimal import Decimal + from mssql_python import getDecimalSeparator + + parts = [] + for value in self: + if isinstance(value, Decimal): + # Apply custom decimal separator for display + sep = getDecimalSeparator() + if sep != '.' and value is not None: + s = str(value) + if '.' in s: + s = s.replace('.', sep) + parts.append(s) + else: + parts.append(str(value)) + else: + parts.append(repr(value)) + + return "(" + ", ".join(parts) + ")" def __repr__(self): """Return a detailed string representation for debugging""" diff --git a/tests/test_001_globals.py b/tests/test_001_globals.py index fbee7ec5e..779d46a81 100644 --- a/tests/test_001_globals.py +++ b/tests/test_001_globals.py @@ -10,7 +10,7 @@ import pytest # Import global variables from the repository -from mssql_python import apilevel, threadsafety, paramstyle, lowercase +from mssql_python import apilevel, threadsafety, paramstyle, lowercase, getDecimalSeparator, setDecimalSeparator def test_apilevel(): # Check if apilevel has the expected value @@ -28,3 +28,29 @@ def test_lowercase(): # Check if lowercase has the expected default value assert lowercase is False, "lowercase should default to False" +def test_decimal_separator(): + """Test decimal separator functionality""" + + # Check default value + assert getDecimalSeparator() == '.', "Default decimal separator should be '.'" + + try: + # Test setting a new value + setDecimalSeparator(',') + assert getDecimalSeparator() == ',', "Decimal separator should be ',' after setting" + + # Test invalid input + with pytest.raises(ValueError): + setDecimalSeparator('too long') + + with pytest.raises(ValueError): + setDecimalSeparator('') + + with pytest.raises(ValueError): + setDecimalSeparator(123) # Non-string input + + finally: + # Restore default value + setDecimalSeparator('.') + assert getDecimalSeparator() == '.', "Decimal separator should be restored to '.'" + diff --git a/tests/test_004_cursor.py b/tests/test_004_cursor.py index 728b27e23..9a63e27f7 100644 --- a/tests/test_004_cursor.py +++ b/tests/test_004_cursor.py @@ -1384,6 +1384,178 @@ def test_lowercase_attribute(cursor, db_connection): except Exception as e: print(f"Warning: Failed to drop test table: {e}") +def test_decimal_separator_function(cursor, db_connection): + """Test decimal separator functionality with database operations""" + # Store original value to restore after test + original_separator = mssql_python.getDecimalSeparator() + + try: + # Create test table + cursor.execute(""" + CREATE TABLE #pytest_decimal_separator_test ( + id INT PRIMARY KEY, + decimal_value DECIMAL(10, 2) + ) + """) + db_connection.commit() + + # Insert test values with default separator (.) + test_value = decimal.Decimal('123.45') + cursor.execute(""" + INSERT INTO #pytest_decimal_separator_test (id, decimal_value) + VALUES (1, ?) + """, [test_value]) + db_connection.commit() + + # First test with default decimal separator (.) + cursor.execute("SELECT id, decimal_value FROM #pytest_decimal_separator_test") + row = cursor.fetchone() + default_str = str(row) + assert '123.45' in default_str, "Default separator not found in string representation" + + # Now change to comma separator and test string representation + mssql_python.setDecimalSeparator(',') + cursor.execute("SELECT id, decimal_value FROM #pytest_decimal_separator_test") + row = cursor.fetchone() + + # This should format the decimal with a comma in the string representation + comma_str = str(row) + assert '123,45' in comma_str, f"Expected comma in string representation but got: {comma_str}" + + finally: + # Restore original decimal separator + mssql_python.setDecimalSeparator(original_separator) + + # Cleanup + cursor.execute("DROP TABLE IF EXISTS #pytest_decimal_separator_test") + db_connection.commit() + +def test_decimal_separator_basic_functionality(): + """Test basic decimal separator functionality without database operations""" + # Store original value to restore after test + original_separator = mssql_python.getDecimalSeparator() + + try: + # Test default value + assert mssql_python.getDecimalSeparator() == '.', "Default decimal separator should be '.'" + + # Test setting to comma + mssql_python.setDecimalSeparator(',') + assert mssql_python.getDecimalSeparator() == ',', "Decimal separator should be ',' after setting" + + # Test setting to other valid separators + mssql_python.setDecimalSeparator(':') + assert mssql_python.getDecimalSeparator() == ':', "Decimal separator should be ':' after setting" + + # Test invalid inputs + with pytest.raises(ValueError): + mssql_python.setDecimalSeparator('') # Empty string + + with pytest.raises(ValueError): + mssql_python.setDecimalSeparator('too_long') # More than one character + + with pytest.raises(ValueError): + mssql_python.setDecimalSeparator(123) # Not a string + + finally: + # Restore original separator + mssql_python.setDecimalSeparator(original_separator) + +def test_decimal_separator_with_multiple_values(cursor, db_connection): + """Test decimal separator with multiple different decimal values""" + original_separator = mssql_python.getDecimalSeparator() + + try: + # Create test table + cursor.execute(""" + CREATE TABLE #pytest_decimal_multi_test ( + id INT PRIMARY KEY, + positive_value DECIMAL(10, 2), + negative_value DECIMAL(10, 2), + zero_value DECIMAL(10, 2), + small_value DECIMAL(10, 4) + ) + """) + db_connection.commit() + + # Insert test data + cursor.execute(""" + INSERT INTO #pytest_decimal_multi_test VALUES (1, 123.45, -67.89, 0.00, 0.0001) + """) + db_connection.commit() + + # Test with default separator first + cursor.execute("SELECT * FROM #pytest_decimal_multi_test") + row = cursor.fetchone() + default_str = str(row) + assert '123.45' in default_str, "Default positive value formatting incorrect" + assert '-67.89' in default_str, "Default negative value formatting incorrect" + + # Change to comma separator + mssql_python.setDecimalSeparator(',') + cursor.execute("SELECT * FROM #pytest_decimal_multi_test") + row = cursor.fetchone() + comma_str = str(row) + + # Verify comma is used in all decimal values + assert '123,45' in comma_str, "Positive value not formatted with comma" + assert '-67,89' in comma_str, "Negative value not formatted with comma" + assert '0,00' in comma_str, "Zero value not formatted with comma" + assert '0,0001' in comma_str, "Small value not formatted with comma" + + finally: + # Restore original separator + mssql_python.setDecimalSeparator(original_separator) + + # Cleanup + cursor.execute("DROP TABLE IF EXISTS #pytest_decimal_multi_test") + db_connection.commit() + +def test_decimal_separator_calculations(cursor, db_connection): + """Test that decimal separator doesn't affect calculations""" + original_separator = mssql_python.getDecimalSeparator() + + try: + # Create test table + cursor.execute(""" + CREATE TABLE #pytest_decimal_calc_test ( + id INT PRIMARY KEY, + value1 DECIMAL(10, 2), + value2 DECIMAL(10, 2) + ) + """) + db_connection.commit() + + # Insert test data + cursor.execute(""" + INSERT INTO #pytest_decimal_calc_test VALUES (1, 10.25, 5.75) + """) + db_connection.commit() + + # Test with default separator + cursor.execute("SELECT value1 + value2 AS sum_result FROM #pytest_decimal_calc_test") + row = cursor.fetchone() + assert row.sum_result == decimal.Decimal('16.00'), "Sum calculation incorrect with default separator" + + # Change to comma separator + mssql_python.setDecimalSeparator(',') + + # Calculations should still work correctly + cursor.execute("SELECT value1 + value2 AS sum_result FROM #pytest_decimal_calc_test") + row = cursor.fetchone() + assert row.sum_result == decimal.Decimal('16.00'), "Sum calculation affected by separator change" + + # But string representation should use comma + assert '16,00' in str(row), "Sum result not formatted with comma in string representation" + + finally: + # Restore original separator + mssql_python.setDecimalSeparator(original_separator) + + # Cleanup + cursor.execute("DROP TABLE IF EXISTS #pytest_decimal_calc_test") + db_connection.commit() + def test_close(db_connection): """Test closing the cursor""" try: From 90271238622f840325cc22668698bec19fc92535 Mon Sep 17 00:00:00 2001 From: Jahnvi Thakkar Date: Mon, 15 Sep 2025 22:47:29 +0530 Subject: [PATCH 3/6] Resolving comments --- mssql_python/__init__.py | 44 ++- mssql_python/pybind/ddbc_bindings.cpp | 15 +- mssql_python/pybind/ddbc_bindings.h | 42 ++- tests/test_001_globals.py | 386 +++++++++++++++++++++++++- 4 files changed, 474 insertions(+), 13 deletions(-) diff --git a/mssql_python/__init__.py b/mssql_python/__init__.py index ec0f3b40a..958af33f8 100644 --- a/mssql_python/__init__.py +++ b/mssql_python/__init__.py @@ -3,6 +3,7 @@ Licensed under the MIT license. This module initializes the mssql_python package. """ +import locale # Exceptions # https://www.python.org/dev/peps/pep-0249/#exceptions @@ -13,10 +14,22 @@ paramstyle = "qmark" threadsafety = 1 +# Initialize the locale setting only once at module import time +# This avoids thread-safety issues with locale +_DEFAULT_DECIMAL_SEPARATOR = "." +try: + # Get the locale setting once during module initialization + _locale_separator = locale.localeconv()['decimal_point'] + if _locale_separator and len(_locale_separator) == 1: + _DEFAULT_DECIMAL_SEPARATOR = _locale_separator +except (AttributeError, KeyError, TypeError, ValueError): + pass # Keep the default "." if locale access fails + class Settings: def __init__(self): self.lowercase = False - self.decimal_separator = "." + # Use the pre-determined separator - no locale access here + self.decimal_separator = _DEFAULT_DECIMAL_SEPARATOR # Global settings instance _settings = Settings() @@ -36,14 +49,37 @@ def setDecimalSeparator(separator): Sets the decimal separator character used when parsing NUMERIC/DECIMAL values from the database, e.g. the "." in "1,234.56". - The default is "." (period). This function overrides the default. + The default is to use the current locale's "decimal_point" value when the module + was first imported, or "." if the locale is not available. This function overrides + the default. Args: separator (str): The character to use as decimal separator + + Raises: + ValueError: If the separator is not a single character string + TypeError: If the separator is not a string """ - if not isinstance(separator, str) or len(separator) != 1: - raise ValueError("Decimal separator must be a single character string") + # Type validation + if not isinstance(separator, str): + raise TypeError("Decimal separator must be a string") + + # Length validation + if len(separator) == 0: + raise ValueError("Decimal separator cannot be empty") + + if len(separator) > 1: + raise ValueError("Decimal separator must be a single character") + + # Character validation + if separator.isspace(): + raise ValueError("Whitespace characters are not allowed as decimal separators") + + # Check for specific disallowed characters + if separator in ['\t', '\n', '\r', '\v', '\f']: + raise ValueError(f"Control character '{repr(separator)}' is not allowed as a decimal separator") + # Set in Python side settings _settings.decimal_separator = separator # Update the C++ side diff --git a/mssql_python/pybind/ddbc_bindings.cpp b/mssql_python/pybind/ddbc_bindings.cpp index b5588a25d..1bdb54c11 100644 --- a/mssql_python/pybind/ddbc_bindings.cpp +++ b/mssql_python/pybind/ddbc_bindings.cpp @@ -2094,11 +2094,15 @@ SQLRETURN FetchBatchData(SQLHSTMT hStmt, ColumnBuffers& buffers, py::list& colum std::string numStr(reinterpret_cast( &buffers.charBuffers[col - 1][i * MAX_DIGITS_IN_NUMERIC]), buffers.indicators[col - 1][i]); - if (g_decimalSeparator != ".") { + + // Get the current separator in a thread-safe way + std::string separator = GetDecimalSeparator(); + + if (separator != ".") { // Replace the driver's decimal point with our configured separator size_t pos = numStr.find('.'); if (pos != std::string::npos) { - numStr.replace(pos, 1, g_decimalSeparator); + numStr.replace(pos, 1, separator); } } @@ -2494,12 +2498,11 @@ void enable_pooling(int maxSize, int idleTimeout) { }); } -// Global decimal separator setting with default value -std::string g_decimalSeparator = "."; +// Thread-safe decimal separator setting +ThreadSafeDecimalSeparator g_decimalSeparator; void DDBCSetDecimalSeparator(const std::string& separator) { - LOG("Setting decimal separator to: {}", separator); - g_decimalSeparator = separator; + SetDecimalSeparator(separator); } // Architecture-specific defines diff --git a/mssql_python/pybind/ddbc_bindings.h b/mssql_python/pybind/ddbc_bindings.h index d142276c6..6b5004129 100644 --- a/mssql_python/pybind/ddbc_bindings.h +++ b/mssql_python/pybind/ddbc_bindings.h @@ -272,8 +272,46 @@ inline std::wstring Utf8ToWString(const std::string& str) { #endif } -// Global decimal separator setting -extern std::string g_decimalSeparator; +// Thread-safe decimal separator accessor class +class ThreadSafeDecimalSeparator { +private: + std::string value; + mutable std::mutex mutex; + +public: + // Constructor with default value + ThreadSafeDecimalSeparator() : value(".") {} + + // Set the decimal separator with thread safety + void set(const std::string& separator) { + std::lock_guard lock(mutex); + value = separator; + } + + // Get the decimal separator with thread safety + std::string get() const { + std::lock_guard lock(mutex); + return value; + } + + // Returns whether the current separator is different from the default "." + bool isCustomSeparator() const { + std::lock_guard lock(mutex); + return value != "."; + } +}; + +// Global instance +extern ThreadSafeDecimalSeparator g_decimalSeparator; + +// Helper functions to replace direct access +inline void SetDecimalSeparator(const std::string& separator) { + g_decimalSeparator.set(separator); +} + +inline std::string GetDecimalSeparator() { + return g_decimalSeparator.get(); +} // Function to set the decimal separator void DDBCSetDecimalSeparator(const std::string& separator); diff --git a/tests/test_001_globals.py b/tests/test_001_globals.py index 779d46a81..040b1dc4d 100644 --- a/tests/test_001_globals.py +++ b/tests/test_001_globals.py @@ -8,6 +8,11 @@ """ import pytest +import threading +import time +import random +import queue +import decimal # Import global variables from the repository from mssql_python import apilevel, threadsafety, paramstyle, lowercase, getDecimalSeparator, setDecimalSeparator @@ -46,7 +51,7 @@ def test_decimal_separator(): with pytest.raises(ValueError): setDecimalSeparator('') - with pytest.raises(ValueError): + with pytest.raises(TypeError): setDecimalSeparator(123) # Non-string input finally: @@ -54,3 +59,382 @@ def test_decimal_separator(): setDecimalSeparator('.') assert getDecimalSeparator() == '.', "Decimal separator should be restored to '.'" +def test_decimal_separator_edge_cases(): + """Test decimal separator edge cases and boundary conditions""" + import decimal + + # Save original separator for restoration + original_separator = getDecimalSeparator() + + try: + # Test 1: Special characters + special_chars = [';', ':', '|', '/', '\\', '*', '+', '-'] + for char in special_chars: + setDecimalSeparator(char) + assert getDecimalSeparator() == char, f"Failed to set special character '{char}' as separator" + + # Test 2: Non-ASCII characters + # Note: Non-ASCII may work for storage but could cause issues with SQL Server + non_ascii_chars = ['€', '¥', '£', '§', 'µ'] + for char in non_ascii_chars: + try: + setDecimalSeparator(char) + assert getDecimalSeparator() == char, f"Failed to set non-ASCII character '{char}' as separator" + except ValueError: + # Some implementations might reject non-ASCII - that's acceptable + pass + + # Test 3: Invalid inputs - additional cases + invalid_inputs = [ + '\t', # Tab character + '\n', # Newline + ' ', # Space + None, # None value + ] + + for invalid in invalid_inputs: + with pytest.raises((ValueError, TypeError)): + setDecimalSeparator(invalid) + + finally: + # Restore original setting + setDecimalSeparator(original_separator) + +def test_decimal_separator_with_db_operations(db_connection): + """Test changing decimal separator during database operations""" + import decimal + + # Save original separator for restoration + original_separator = getDecimalSeparator() + + try: + # Create a test table with decimal values + cursor = db_connection.cursor() + cursor.execute(""" + DROP TABLE IF EXISTS #decimal_separator_test; + CREATE TABLE #decimal_separator_test ( + id INT, + decimal_value DECIMAL(10,2) + ); + INSERT INTO #decimal_separator_test VALUES + (1, 123.45), + (2, 678.90), + (3, 0.01), + (4, 999.99); + """) + cursor.close() + + # Test 1: Fetch with default separator + cursor1 = db_connection.cursor() + cursor1.execute("SELECT decimal_value FROM #decimal_separator_test WHERE id = 1") + value1 = cursor1.fetchone()[0] + assert isinstance(value1, decimal.Decimal) + assert str(value1) == "123.45", f"Expected 123.45, got {value1} with separator '{getDecimalSeparator()}'" + + # Test 2: Change separator and fetch new data + setDecimalSeparator(',') + cursor2 = db_connection.cursor() + cursor2.execute("SELECT decimal_value FROM #decimal_separator_test WHERE id = 2") + value2 = cursor2.fetchone()[0] + assert isinstance(value2, decimal.Decimal) + assert str(value2).replace('.', ',') == "678,90", f"Expected 678,90, got {str(value2).replace('.', ',')} with separator ','" + + # Test 3: The previously fetched value should not be affected by separator change + assert str(value1) == "123.45", f"Previously fetched value changed after separator modification" + + # Test 4: Change separator back and forth multiple times + separators_to_test = ['.', ',', ';', '.', ',', '.'] + for i, sep in enumerate(separators_to_test, start=3): + setDecimalSeparator(sep) + assert getDecimalSeparator() == sep, f"Failed to set separator to '{sep}'" + + # Fetch new data with current separator + cursor = db_connection.cursor() + cursor.execute(f"SELECT decimal_value FROM #decimal_separator_test WHERE id = {i % 4 + 1}") + value = cursor.fetchone()[0] + assert isinstance(value, decimal.Decimal), f"Value should be Decimal with separator '{sep}'" + + # Verify string representation uses the current separator + # Note: decimal.Decimal always uses '.' in string representation, so we replace for comparison + decimal_str = str(value).replace('.', sep) + assert sep in decimal_str or decimal_str.endswith('0'), f"Decimal string should contain separator '{sep}'" + + finally: + # Clean up - Fixed: use cursor.execute instead of db_connection.execute + cursor = db_connection.cursor() + cursor.execute("DROP TABLE IF EXISTS #decimal_separator_test") + cursor.close() + setDecimalSeparator(original_separator) + +def test_decimal_separator_batch_operations(db_connection): + """Test decimal separator behavior with batch operations and result sets""" + import decimal + + # Save original separator for restoration + original_separator = getDecimalSeparator() + + try: + # Create test data + cursor = db_connection.cursor() + cursor.execute(""" + DROP TABLE IF EXISTS #decimal_batch_test; + CREATE TABLE #decimal_batch_test ( + id INT, + value1 DECIMAL(10,3), + value2 DECIMAL(12,5) + ); + INSERT INTO #decimal_batch_test VALUES + (1, 123.456, 12345.67890), + (2, 0.001, 0.00001), + (3, 999.999, 9999.99999); + """) + cursor.close() + + # Test 1: Fetch results with default separator + setDecimalSeparator('.') + cursor1 = db_connection.cursor() + cursor1.execute("SELECT * FROM #decimal_batch_test ORDER BY id") + results1 = cursor1.fetchall() + cursor1.close() + + # Important: Verify Python Decimal objects always use "." internally + # regardless of separator setting (pyodbc-compatible behavior) + for row in results1: + assert isinstance(row[1], decimal.Decimal), "Results should be Decimal objects" + assert isinstance(row[2], decimal.Decimal), "Results should be Decimal objects" + assert '.' in str(row[1]), "Decimal string representation should use '.'" + assert '.' in str(row[2]), "Decimal string representation should use '.'" + + # Change separator before processing results + setDecimalSeparator(',') + + # Verify results use the separator that was active during fetch + # This tests that previously fetched values aren't affected by separator changes + for row in results1: + assert '.' in str(row[1]), f"Expected '.' in {row[1]} from first result set" + assert '.' in str(row[2]), f"Expected '.' in {row[2]} from first result set" + + # Test 2: Fetch new results with new separator + cursor2 = db_connection.cursor() + cursor2.execute("SELECT * FROM #decimal_batch_test ORDER BY id") + results2 = cursor2.fetchall() + cursor2.close() + + # Check if implementation supports separator changes + # In some versions of pyodbc, changing separator might cause NULL values + has_nulls = any(any(v is None for v in row) for row in results2 if row is not None) + + if has_nulls: + print("NOTE: Decimal separator change resulted in NULL values - this is compatible with some pyodbc versions") + # Skip further numeric comparisons + else: + # Test 3: Verify values are equal regardless of separator used during fetch + assert len(results1) == len(results2), "Both result sets should have same number of rows" + + for i in range(len(results1)): + # IDs should match + assert results1[i][0] == results2[i][0], f"Row {i} IDs don't match" + + # Decimal values should be numerically equal even with different separators + if results2[i][1] is not None and results1[i][1] is not None: + assert float(results1[i][1]) == float(results2[i][1]), f"Row {i} value1 should be numerically equal" + + if results2[i][2] is not None and results1[i][2] is not None: + assert float(results1[i][2]) == float(results2[i][2]), f"Row {i} value2 should be numerically equal" + + # Reset separator for further tests + setDecimalSeparator('.') + + finally: + # Clean up + cursor = db_connection.cursor() + cursor.execute("DROP TABLE IF EXISTS #decimal_batch_test") + cursor.close() + setDecimalSeparator(original_separator) + +def test_decimal_separator_thread_safety(): + """Test thread safety of decimal separator with multiple concurrent threads""" + + # Save original separator for restoration + original_separator = getDecimalSeparator() + + # Create a shared event for synchronizing threads + ready_event = threading.Event() + stop_event = threading.Event() + + # Create a list to track errors from threads + errors = [] + + def change_separator_worker(): + """Worker that repeatedly changes the decimal separator""" + separators = ['.', ',', ';', ':', '-', '|'] + + # Wait for the start signal + ready_event.wait() + + try: + # Rapidly change separators until told to stop + while not stop_event.is_set(): + sep = random.choice(separators) + setDecimalSeparator(sep) + time.sleep(0.001) # Small delay to allow other threads to run + except Exception as e: + errors.append(f"Changer thread error: {str(e)}") + + def read_separator_worker(): + """Worker that repeatedly reads the current separator""" + # Wait for the start signal + ready_event.wait() + + try: + # Continuously read the separator until told to stop + while not stop_event.is_set(): + separator = getDecimalSeparator() + # Verify the separator is a valid string and not corrupted + if not isinstance(separator, str) or len(separator) != 1: + errors.append(f"Invalid separator read: {repr(separator)}") + time.sleep(0.001) # Small delay to allow other threads to run + except Exception as e: + errors.append(f"Reader thread error: {str(e)}") + + try: + # Create multiple threads that change and read the separator + changer_threads = [threading.Thread(target=change_separator_worker) for _ in range(3)] + reader_threads = [threading.Thread(target=read_separator_worker) for _ in range(5)] + + # Start all threads + for t in changer_threads + reader_threads: + t.start() + + # Allow threads to initialize + time.sleep(0.1) + + # Signal threads to begin work + ready_event.set() + + # Let threads run for a short time + time.sleep(0.5) + + # Signal threads to stop + stop_event.set() + + # Wait for all threads to finish + for t in changer_threads + reader_threads: + t.join(timeout=1.0) + + # Check for any errors reported by threads + assert not errors, f"Thread safety errors detected: {errors}" + + finally: + # Restore original separator + stop_event.set() # Ensure all threads will stop + setDecimalSeparator(original_separator) + +def test_decimal_separator_concurrent_db_operations(db_connection): + """Test thread safety with concurrent database operations and separator changes. + This test verifies that multiple threads can safely change and read the decimal separator.""" + import decimal + import threading + import queue + import random + import time + + # Save original separator for restoration + original_separator = getDecimalSeparator() + + # Create a shared queue with a maximum size + results_queue = queue.Queue(maxsize=100) + + # Create events for synchronization + stop_event = threading.Event() + + # Set a global timeout for the entire test + test_timeout = time.time() + 10 # 10 second maximum test duration + + # Extract connection string + connection_str = db_connection.connection_str + + # We'll use a simpler approach - no temporary tables + # Just verify the decimal separator can be changed safely + + def separator_changer_worker(): + """Worker that changes the decimal separator repeatedly""" + separators = ['.', ',', ';'] + count = 0 + + try: + while not stop_event.is_set() and count < 10 and time.time() < test_timeout: + sep = random.choice(separators) + setDecimalSeparator(sep) + results_queue.put(('change', sep)) + count += 1 + time.sleep(0.1) # Slow down to avoid overwhelming the system + except Exception as e: + results_queue.put(('error', f"Changer error: {str(e)}")) + + def separator_reader_worker(): + """Worker that reads the current separator""" + count = 0 + + try: + while not stop_event.is_set() and count < 20 and time.time() < test_timeout: + current = getDecimalSeparator() + results_queue.put(('read', current)) + count += 1 + time.sleep(0.05) + except Exception as e: + results_queue.put(('error', f"Reader error: {str(e)}")) + + # Use daemon threads that won't block test exit + threads = [ + threading.Thread(target=separator_changer_worker, daemon=True), + threading.Thread(target=separator_reader_worker, daemon=True) + ] + + # Start all threads + for t in threads: + t.start() + + try: + # Wait until the test timeout or all threads complete + end_time = time.time() + 5 # 5 second test duration + while time.time() < end_time and any(t.is_alive() for t in threads): + time.sleep(0.1) + + # Signal threads to stop + stop_event.set() + + # Give threads a short time to wrap up + for t in threads: + t.join(timeout=0.5) + + # Process results + errors = [] + changes = [] + reads = [] + + # Collect results with timeout + timeout_end = time.time() + 1 + while not results_queue.empty() and time.time() < timeout_end: + try: + item = results_queue.get(timeout=0.1) + if item[0] == 'error': + errors.append(item[1]) + elif item[0] == 'change': + changes.append(item[1]) + elif item[0] == 'read': + reads.append(item[1]) + except queue.Empty: + break + + # Verify we got results + assert not errors, f"Thread errors detected: {errors}" + assert changes, "No separator changes were recorded" + assert reads, "No separator reads were recorded" + + print(f"Successfully performed {len(changes)} separator changes and {len(reads)} reads") + + finally: + # Always make sure to clean up + stop_event.set() + setDecimalSeparator(original_separator) \ No newline at end of file From 9604bf822fe6eed5b9327512a8b1cb139b15f7ec Mon Sep 17 00:00:00 2001 From: Jahnvi Thakkar Date: Thu, 18 Sep 2025 10:50:01 +0530 Subject: [PATCH 4/6] Resolving conflicts --- mssql_python/pybind/ddbc_bindings.cpp | 32 +++++++++++++++++++++++---- tests/test_001_globals.py | 4 ---- tests/test_004_cursor.py | 4 ---- 3 files changed, 28 insertions(+), 12 deletions(-) diff --git a/mssql_python/pybind/ddbc_bindings.cpp b/mssql_python/pybind/ddbc_bindings.cpp index 44fb11769..507b3a34c 100644 --- a/mssql_python/pybind/ddbc_bindings.cpp +++ b/mssql_python/pybind/ddbc_bindings.cpp @@ -2038,12 +2038,36 @@ SQLRETURN SQLGetData_wrap(SqlHandlePtr StatementHandle, SQLUSMALLINT colCount, p if (SQL_SUCCEEDED(ret)) { try { - // Use the original string with period for Python's Decimal constructor - std::string numStr(reinterpret_cast(numericStr), indicator); - + // Validate 'indicator' to avoid buffer overflow and fallback to a safe + // null-terminated read when length is unknown or out-of-range. + const char* cnum = reinterpret_cast(numericStr); + size_t bufSize = sizeof(numericStr); + size_t safeLen = 0; + + if (indicator > 0 && indicator <= static_cast(bufSize)) { + // indicator appears valid and within the buffer size + safeLen = static_cast(indicator); + } else { + // indicator is unknown, zero, negative, or too large; determine length + // by searching for a terminating null (safe bounded scan) + for (size_t j = 0; j < bufSize; ++j) { + if (cnum[j] == '\0') { + safeLen = j; + break; + } + } + // if no null found, use the full buffer size as a conservative fallback + if (safeLen == 0 && bufSize > 0 && cnum[0] != '\0') { + safeLen = bufSize; + } + } + + // Use the validated length to construct the string for Decimal + std::string numStr(cnum, safeLen); + // Create Python Decimal object py::object decimalObj = py::module_::import("decimal").attr("Decimal")(numStr); - + // Add to row row.append(decimalObj); } catch (const py::error_already_set& e) { diff --git a/tests/test_001_globals.py b/tests/test_001_globals.py index fffb7abe8..5b98656e2 100644 --- a/tests/test_001_globals.py +++ b/tests/test_001_globals.py @@ -11,11 +11,7 @@ import threading import time import mssql_python -import threading -import time import random -import queue -import decimal # Import global variables from the repository from mssql_python import apilevel, threadsafety, paramstyle, lowercase, getDecimalSeparator, setDecimalSeparator diff --git a/tests/test_004_cursor.py b/tests/test_004_cursor.py index 0b3888518..99bceaa8d 100644 --- a/tests/test_004_cursor.py +++ b/tests/test_004_cursor.py @@ -9,13 +9,9 @@ """ import pytest -import sys from datetime import datetime, date, time import decimal from contextlib import closing -from mssql_python import Connection, row -import mssql_python -from mssql_python.exceptions import InterfaceError import mssql_python # Setup test table From e8c3ac411c7a0b97faaa0895c37f2b2b74ef6ac7 Mon Sep 17 00:00:00 2001 From: Jahnvi Thakkar Date: Thu, 18 Sep 2025 11:15:21 +0530 Subject: [PATCH 5/6] Resolving conflicts --- mssql_python/__init__.py | 3 +-- tests/test_004_cursor.py | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/mssql_python/__init__.py b/mssql_python/__init__.py index 7a8767a25..ce34a7f32 100644 --- a/mssql_python/__init__.py +++ b/mssql_python/__init__.py @@ -63,11 +63,10 @@ def setDecimalSeparator(separator): Raises: ValueError: If the separator is not a single character string - TypeError: If the separator is not a string """ # Type validation if not isinstance(separator, str): - raise TypeError("Decimal separator must be a string") + raise ValueError("Decimal separator must be a string") # Length validation if len(separator) == 0: diff --git a/tests/test_004_cursor.py b/tests/test_004_cursor.py index 99bceaa8d..18cce9ba4 100644 --- a/tests/test_004_cursor.py +++ b/tests/test_004_cursor.py @@ -7150,7 +7150,7 @@ def test_decimal_separator_basic_functionality(): with pytest.raises(ValueError): mssql_python.setDecimalSeparator('too_long') # More than one character - with pytest.raises(TypeError): + with pytest.raises(ValueError): mssql_python.setDecimalSeparator(123) # Not a string finally: From f3712b8c2a6d5f97fcc50dd8b26d9bee4b0294e8 Mon Sep 17 00:00:00 2001 From: Jahnvi Thakkar Date: Thu, 18 Sep 2025 11:32:38 +0530 Subject: [PATCH 6/6] Resolving conflicts --- tests/test_001_globals.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_001_globals.py b/tests/test_001_globals.py index 5b98656e2..b0c28989c 100644 --- a/tests/test_001_globals.py +++ b/tests/test_001_globals.py @@ -170,7 +170,7 @@ def test_decimal_separator(): with pytest.raises(ValueError): setDecimalSeparator('') - with pytest.raises(TypeError): + with pytest.raises(ValueError): setDecimalSeparator(123) # Non-string input finally: