Skip to content

Commit 4b42f8c

Browse files
authored
FIX: Closed Attribute in cursor __del__ (#183)
### Work Item / Issue Reference <!-- IMPORTANT: Please follow the PR template guidelines below. For mssql-python maintainers: Insert your ADO Work Item ID below (e.g. AB#37452) For external contributors: Insert Github Issue number below (e.g. #149) Only one reference is required - either GitHub issue OR ADO Work Item. --> <!-- mssql-python maintainers: ADO Work Item --> > AB#<WORK_ITEM_ID> <!-- External contributors: GitHub Issue --> > GitHub Issue: #182 ------------------------------------------------------------------- ### Summary <!-- Insert your summary of changes below. Minimum 10 characters required. --> This pull request makes improvements to the cursor resource management and error handling in the `mssql_python/cursor.py` module. The main changes include making the cursor close operation idempotent, refining exception types, and improving logging during cleanup. These updates help ensure more robust and predictable behavior when working with cursors. Error handling improvements: * Changed the exception raised when attempting to close an already closed cursor from a generic `Exception` to a more specific `ProgrammingError`, providing clearer feedback to users. * Updated the import statement to use `ProgrammingError` instead of `InterfaceError`, aligning with the new error handling approach. Resource cleanup and logging: * Improved the destructor (`__del__`) to check for the correct `closed` attribute and made sure no exception is raised if the cursor is already closed, enhancing reliability during garbage collection. * Changed the logging level in the destructor from 'error' to 'debug' for exceptions during cleanup, reducing unnecessary noise in error logs. <!-- ### PR Title Guide > For feature requests FEAT: (short-description) > For non-feature requests like test case updates, config updates , dependency updates etc CHORE: (short-description) > For Fix requests FIX: (short-description) > For doc update requests DOC: (short-description) > For Formatting, indentation, or styling update STYLE: (short-description) > For Refactor, without any feature changes REFACTOR: (short-description) > For release related changes, without any feature changes RELEASE: #<RELEASE_VERSION> (short-description) ### Contribution Guidelines External contributors: - Create a GitHub issue first: https://github.com/microsoft/mssql-python/issues/new - Link the GitHub issue in the "GitHub Issue" section above - Follow the PR title format and provide a meaningful summary mssql-python maintainers: - Create an ADO Work Item following internal processes - Link the ADO Work Item in the "ADO Work Item" section above - Follow the PR title format and provide a meaningful summary -->
1 parent f44d84b commit 4b42f8c

File tree

4 files changed

+243
-22
lines changed

4 files changed

+243
-22
lines changed

mssql_python/cursor.py

Lines changed: 19 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -471,17 +471,16 @@ def _reset_cursor(self) -> None:
471471

472472
def close(self) -> None:
473473
"""
474-
Close the cursor now (rather than whenever __del__ is called).
474+
Close the connection now (rather than whenever .__del__() is called).
475+
Idempotent: subsequent calls have no effect and will be no-ops.
475476
476477
The cursor will be unusable from this point forward; an InterfaceError
477-
will be raised if any operation is attempted with the cursor.
478-
479-
Note:
480-
Unlike the current behavior, this method can be called multiple times safely.
481-
Subsequent calls to close() on an already closed cursor will have no effect.
478+
will be raised if any operation (other than close) is attempted with the cursor.
479+
This is a deviation from pyodbc, which raises an exception if the cursor is already closed.
482480
"""
483481
if self.closed:
484-
return
482+
# Do nothing - not calling _check_closed() here since we want this to be idempotent
483+
return
485484

486485
# Clear messages per DBAPI
487486
self.messages = []
@@ -498,12 +497,12 @@ def _check_closed(self):
498497
Check if the cursor is closed and raise an exception if it is.
499498
500499
Raises:
501-
InterfaceError: If the cursor is closed.
500+
ProgrammingError: If the cursor is closed.
502501
"""
503502
if self.closed:
504-
raise InterfaceError(
505-
driver_error="Operation cannot be performed: the cursor is closed.",
506-
ddbc_error="Operation cannot be performed: the cursor is closed."
503+
raise ProgrammingError(
504+
driver_error="Operation cannot be performed: The cursor is closed.",
505+
ddbc_error=""
507506
)
508507

509508
def _create_parameter_types_list(self, parameter, param_info, parameters_list, i):
@@ -1185,13 +1184,19 @@ def __del__(self):
11851184
Destructor to ensure the cursor is closed when it is no longer needed.
11861185
This is a safety net to ensure resources are cleaned up
11871186
even if close() was not called explicitly.
1187+
If the cursor is already closed, it will not raise an exception during cleanup.
11881188
"""
1189-
if "_closed" not in self.__dict__ or not self._closed:
1189+
if "closed" not in self.__dict__ or not self.closed:
11901190
try:
11911191
self.close()
11921192
except Exception as e:
11931193
# Don't raise an exception in __del__, just log it
1194-
log('error', "Error during cursor cleanup in __del__: %s", e)
1194+
# If interpreter is shutting down, we might not have logging set up
1195+
import sys
1196+
if sys and sys._is_finalizing():
1197+
# Suppress logging during interpreter shutdown
1198+
return
1199+
log('debug', "Exception during cursor cleanup in __del__: %s", e)
11951200

11961201
def scroll(self, value: int, mode: str = 'relative') -> None:
11971202
"""
@@ -1430,4 +1435,4 @@ def tables(self, table=None, catalog=None, schema=None, tableType=None):
14301435
row = Row(row_data, self.description, column_map)
14311436
result_rows.append(row)
14321437

1433-
return result_rows
1438+
return result_rows

mssql_python/exceptions.py

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,14 @@ class Exception(Exception):
1717
def __init__(self, driver_error, ddbc_error) -> None:
1818
self.driver_error = driver_error
1919
self.ddbc_error = truncate_error_message(ddbc_error)
20-
self.message = (
21-
f"Driver Error: {self.driver_error}; DDBC Error: {self.ddbc_error}"
22-
)
20+
if self.ddbc_error:
21+
# Both driver and DDBC errors are present
22+
self.message = (
23+
f"Driver Error: {self.driver_error}; DDBC Error: {self.ddbc_error}"
24+
)
25+
else:
26+
# Errors raised by the driver itself should not have a DDBC error message
27+
self.message = f"Driver Error: {self.driver_error}"
2328
super().__init__(self.message)
2429

2530

tests/test_003_connection.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1528,7 +1528,7 @@ def test_connection_exception_catching_with_connection_attributes(db_connection)
15281528
cursor.close()
15291529
cursor.execute("SELECT 1") # Should raise InterfaceError on closed cursor
15301530
pytest.fail("Should have raised an exception")
1531-
except db_connection.InterfaceError as e:
1531+
except db_connection.ProgrammingError as e:
15321532
assert "closed" in str(e).lower(), "Error message should mention closed cursor"
15331533
except Exception as e:
15341534
pytest.fail(f"Should have caught InterfaceError, but got {type(e).__name__}: {e}")
@@ -1567,12 +1567,12 @@ def test_connection_exception_multi_connection_scenario(conn_str):
15671567
try:
15681568
cursor1.execute("SELECT 1")
15691569
pytest.fail("Should have raised an exception")
1570-
except conn1.InterfaceError as e:
1571-
# Using conn1.InterfaceError even though conn1 is closed
1570+
except conn1.ProgrammingError as e:
1571+
# Using conn1.ProgrammingError even though conn1 is closed
15721572
# The exception class attribute should still be accessible
15731573
assert "closed" in str(e).lower(), "Should mention closed cursor"
15741574
except Exception as e:
1575-
pytest.fail(f"Expected InterfaceError from conn1 attributes, got {type(e).__name__}: {e}")
1575+
pytest.fail(f"Expected ProgrammingError from conn1 attributes, got {type(e).__name__}: {e}")
15761576

15771577
# Second connection should still work
15781578
cursor2.execute("SELECT 1")

tests/test_005_connection_cursor_lifecycle.py

Lines changed: 212 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,15 @@
1313
- test_connection_close_idempotent: Tests that calling close() multiple times is safe.
1414
- test_cursor_after_connection_close: Tests that creating a cursor after closing the connection raises an error.
1515
- test_multiple_cursor_operations_cleanup: Tests cleanup with multiple cursor operations.
16+
- test_cursor_close_raises_on_double_close: Tests that closing a cursor twice raises a ProgrammingError.
17+
- test_cursor_del_no_logging_during_shutdown: Tests that cursor __del__ doesn't log errors during interpreter shutdown.
18+
- test_cursor_del_on_closed_cursor_no_errors: Tests that __del__ on already closed cursor doesn't produce error logs.
19+
- test_cursor_del_unclosed_cursor_cleanup: Tests that __del__ properly cleans up unclosed cursors without errors.
20+
- test_cursor_operations_after_close_raise_errors: Tests that all cursor operations raise appropriate errors after close.
21+
- test_mixed_cursor_cleanup_scenarios: Tests various mixed cleanup scenarios in one script.
1622
"""
1723

24+
import os
1825
import pytest
1926
import subprocess
2027
import sys
@@ -263,4 +270,208 @@ def test_multiple_cursor_operations_cleanup(conn_str):
263270
# All cursors should be closed
264271
assert cursor_insert.closed is True
265272
assert cursor_select1.closed is True
266-
assert cursor_select2.closed is True
273+
assert cursor_select2.closed is True
274+
275+
def test_cursor_close_raises_on_double_close(conn_str):
276+
"""Test that closing a cursor twice raises ProgrammingError"""
277+
conn = connect(conn_str)
278+
cursor = conn.cursor()
279+
cursor.execute("SELECT 1")
280+
cursor.fetchall()
281+
282+
# First close should succeed
283+
cursor.close()
284+
assert cursor.closed is True
285+
286+
# Second close should be a no-op and silent - not raise an error
287+
cursor.close()
288+
assert cursor.closed is True
289+
290+
291+
def test_cursor_del_no_logging_during_shutdown(conn_str, tmp_path):
292+
"""Test that cursor __del__ doesn't log errors during interpreter shutdown"""
293+
code = f"""
294+
from mssql_python import connect
295+
296+
# Create connection and cursor
297+
conn = connect(\"\"\"{conn_str}\"\"\")
298+
cursor = conn.cursor()
299+
cursor.execute("SELECT 1")
300+
cursor.fetchall()
301+
302+
# Don't close cursor - let __del__ handle it during shutdown
303+
# This should not produce any log output during interpreter shutdown
304+
print("Test completed successfully")
305+
"""
306+
307+
result = subprocess.run(
308+
[sys.executable, "-c", code],
309+
capture_output=True,
310+
text=True
311+
)
312+
313+
# Should exit cleanly
314+
assert result.returncode == 0, f"Script failed: {result.stderr}"
315+
# Should not have any debug/error logs about cursor cleanup
316+
assert "Exception during cursor cleanup" not in result.stderr
317+
assert "Exception during cursor cleanup" not in result.stdout
318+
# Should have our success message
319+
assert "Test completed successfully" in result.stdout
320+
321+
322+
def test_cursor_del_on_closed_cursor_no_errors(conn_str, caplog):
323+
"""Test that __del__ on already closed cursor doesn't produce error logs"""
324+
import logging
325+
caplog.set_level(logging.DEBUG)
326+
327+
conn = connect(conn_str)
328+
cursor = conn.cursor()
329+
cursor.execute("SELECT 1")
330+
cursor.fetchall()
331+
332+
# Close cursor explicitly
333+
cursor.close()
334+
335+
# Clear any existing logs
336+
caplog.clear()
337+
338+
# Delete the cursor - should not produce any logs
339+
del cursor
340+
import gc
341+
gc.collect()
342+
343+
# Check that no error logs were produced
344+
for record in caplog.records:
345+
assert "Exception during cursor cleanup" not in record.message
346+
assert "Operation cannot be performed: The cursor is closed." not in record.message
347+
348+
conn.close()
349+
350+
351+
def test_cursor_del_unclosed_cursor_cleanup(conn_str):
352+
"""Test that __del__ properly cleans up unclosed cursors without errors"""
353+
code = f"""
354+
from mssql_python import connect
355+
356+
# Create connection and cursor
357+
conn = connect(\"\"\"{conn_str}\"\"\")
358+
cursor = conn.cursor()
359+
cursor.execute("SELECT 1")
360+
cursor.fetchall()
361+
362+
# Store cursor state before deletion
363+
cursor_closed_before = cursor.closed
364+
365+
# Delete cursor without closing - __del__ should handle cleanup
366+
del cursor
367+
import gc
368+
gc.collect()
369+
370+
# Verify cursor was not closed before deletion
371+
assert cursor_closed_before is False, "Cursor should not be closed before deletion"
372+
373+
# Close connection
374+
conn.close()
375+
print("Cleanup successful")
376+
"""
377+
378+
result = subprocess.run(
379+
[sys.executable, "-c", code],
380+
capture_output=True,
381+
text=True
382+
)
383+
assert result.returncode == 0, f"Expected successful cleanup, but got: {result.stderr}"
384+
assert "Cleanup successful" in result.stdout
385+
# Should not have any error messages
386+
assert "Exception" not in result.stderr
387+
388+
389+
def test_cursor_operations_after_close_raise_errors(conn_str):
390+
"""Test that all cursor operations raise appropriate errors after close"""
391+
conn = connect(conn_str)
392+
cursor = conn.cursor()
393+
cursor.execute("SELECT 1")
394+
cursor.fetchall()
395+
396+
# Close the cursor
397+
cursor.close()
398+
399+
# All operations should raise exceptions
400+
with pytest.raises(Exception) as excinfo:
401+
cursor.execute("SELECT 2")
402+
assert "Operation cannot be performed: The cursor is closed." in str(excinfo.value)
403+
404+
with pytest.raises(Exception) as excinfo:
405+
cursor.fetchone()
406+
assert "Operation cannot be performed: The cursor is closed." in str(excinfo.value)
407+
408+
with pytest.raises(Exception) as excinfo:
409+
cursor.fetchmany(5)
410+
assert "Operation cannot be performed: The cursor is closed." in str(excinfo.value)
411+
412+
with pytest.raises(Exception) as excinfo:
413+
cursor.fetchall()
414+
assert "Operation cannot be performed: The cursor is closed." in str(excinfo.value)
415+
416+
conn.close()
417+
418+
419+
def test_mixed_cursor_cleanup_scenarios(conn_str, tmp_path):
420+
"""Test various mixed cleanup scenarios in one script"""
421+
code = f"""
422+
from mssql_python import connect
423+
from mssql_python.exceptions import ProgrammingError
424+
425+
# Test 1: Normal cursor close
426+
conn1 = connect(\"\"\"{conn_str}\"\"\")
427+
cursor1 = conn1.cursor()
428+
cursor1.execute("SELECT 1")
429+
cursor1.fetchall()
430+
cursor1.close()
431+
432+
# Test 2: Double close does not raise error
433+
cursor1.close()
434+
print("PASS: Double close does not raise error")
435+
436+
# Test 3: Cursor cleanup via __del__
437+
cursor2 = conn1.cursor()
438+
cursor2.execute("SELECT 2")
439+
cursor2.fetchall()
440+
# Don't close cursor2, let __del__ handle it
441+
442+
# Test 4: Connection close cleans up cursors
443+
conn2 = connect(\"\"\"{conn_str}\"\"\")
444+
cursor3 = conn2.cursor()
445+
cursor4 = conn2.cursor()
446+
cursor3.execute("SELECT 3")
447+
cursor3.fetchall()
448+
cursor4.execute("SELECT 4")
449+
cursor4.fetchall()
450+
conn2.close() # Should close both cursors
451+
452+
# Verify cursors are closed
453+
assert cursor3.closed is True
454+
assert cursor4.closed is True
455+
print("PASS: Connection close cleaned up cursors")
456+
457+
# Clean up
458+
conn1.close()
459+
print("All tests passed")
460+
"""
461+
462+
result = subprocess.run(
463+
[sys.executable, "-c", code],
464+
capture_output=True,
465+
text=True
466+
)
467+
468+
if result.returncode != 0:
469+
print(f"STDOUT: {result.stdout}")
470+
print(f"STDERR: {result.stderr}")
471+
472+
assert result.returncode == 0, f"Script failed: {result.stderr}"
473+
assert "PASS: Double close does not raise error" in result.stdout
474+
assert "PASS: Connection close cleaned up cursors" in result.stdout
475+
assert "All tests passed" in result.stdout
476+
# Should not have error logs
477+
assert "Exception during cursor cleanup" not in result.stderr

0 commit comments

Comments
 (0)