Skip to content

Commit f4c899e

Browse files
jahnvi480bewithgauravCopilot
authored
FEAT: Context manager support for connection and cursor class (#160)
### 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#38074](https://sqlclientdrivers.visualstudio.com/c6d89619-62de-46a0-8b46-70b92a84d85e/_workitems/edit/38074) > GitHub Issue: #23 ------------------------------------------------------------------- ### Summary This pull request introduces context manager support for the `Connection` and `Cursor` classes in the `mssql_python` module, aligning their behavior with `pyodbc`. It also adds extensive tests to ensure correct functionality, covering various scenarios such as normal exits, exceptions, nested transactions, and manual commit/rollback. The key changes are grouped below: ### Context Manager Support * Added `__enter__` and `__exit__` methods to the `Connection` class to enable usage with the `with` statement. The connection commits transactions on normal exit and rolls back on exceptions, without closing the connection. * Added `__enter__` and `__exit__` methods to the `Cursor` class to allow usage with the `with` statement. The cursor commits transactions on normal exit but does not close itself, aligning with `pyodbc` behavior. ### Logging Enhancements * Enhanced the `Connection.close` method to log when uncommitted changes are rolled back before closing. ### Test Coverage * Added new tests in `tests/test_003_connection.py` to verify context manager behavior for connections, including: - Committing transactions on normal exit. - Rolling back transactions on exceptions. - Handling autocommit mode. - Ensuring connections remain open after context exit. - Supporting nested context managers. - Testing manual commit/rollback within a context manager. * Added a test for using `contextlib.closing` with connections to ensure proper closure after context exit. * Updated `tests/test_004_cursor.py` to include `contextlib.closing` for cursor tests. --------- Co-authored-by: Jahnvi Thakkar <jathakkar@microsoft.com> Co-authored-by: Gaurav Sharma <sharmag@microsoft.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
1 parent 23a6d44 commit f4c899e

File tree

4 files changed

+464
-2
lines changed

4 files changed

+464
-2
lines changed

mssql_python/connection.py

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,13 +42,32 @@ class Connection:
4242
to be used in a context where database operations are required, such as executing queries
4343
and fetching results.
4444
45+
The Connection class supports the Python context manager protocol (with statement).
46+
When used as a context manager, it will automatically close the connection when
47+
exiting the context, ensuring proper resource cleanup.
48+
49+
Example usage:
50+
with connect(connection_string) as conn:
51+
cursor = conn.cursor()
52+
cursor.execute("INSERT INTO table VALUES (?)", [value])
53+
# Connection is automatically closed when exiting the with block
54+
55+
For long-lived connections, use without context manager:
56+
conn = connect(connection_string)
57+
try:
58+
# Multiple operations...
59+
finally:
60+
conn.close()
61+
4562
Methods:
4663
__init__(database: str) -> None:
4764
connect_to_db() -> None:
4865
cursor() -> Cursor:
4966
commit() -> None:
5067
rollback() -> None:
5168
close() -> None:
69+
__enter__() -> Connection:
70+
__exit__() -> None:
5271
"""
5372

5473
# DB-API 2.0 Exception attributes
@@ -289,6 +308,7 @@ def close(self) -> None:
289308
# If autocommit is disabled, rollback any uncommitted changes
290309
# This is important to ensure no partial transactions remain
291310
# For autocommit True, this is not necessary as each statement is committed immediately
311+
log('info', "Rolling back uncommitted changes before closing connection.")
292312
self._conn.rollback()
293313
# TODO: Check potential race conditions in case of multithreaded scenarios
294314
# Close the connection
@@ -304,6 +324,35 @@ def close(self) -> None:
304324

305325
log('info', "Connection closed successfully.")
306326

327+
def __enter__(self) -> 'Connection':
328+
"""
329+
Enter the context manager.
330+
331+
This method enables the Connection to be used with the 'with' statement.
332+
When entering the context, it simply returns the connection object itself.
333+
334+
Returns:
335+
Connection: The connection object itself.
336+
337+
Example:
338+
with connect(connection_string) as conn:
339+
cursor = conn.cursor()
340+
cursor.execute("INSERT INTO table VALUES (?)", [value])
341+
# Transaction will be committed automatically when exiting
342+
"""
343+
log('info', "Entering connection context manager.")
344+
return self
345+
346+
def __exit__(self, *args) -> None:
347+
"""
348+
Exit the context manager.
349+
350+
Closes the connection when exiting the context, ensuring proper resource cleanup.
351+
This follows the modern standard used by most database libraries.
352+
"""
353+
if not self._closed:
354+
self.close()
355+
307356
def __del__(self):
308357
"""
309358
Destructor to ensure the connection is closed when the connection object is no longer needed.

mssql_python/cursor.py

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
from mssql_python.constants import ConstantsDDBC as ddbc_sql_const
1717
from mssql_python.helpers import check_error, log
1818
from mssql_python import ddbc_bindings
19-
from mssql_python.exceptions import InterfaceError
19+
from mssql_python.exceptions import InterfaceError, ProgrammingError
2020
from .row import Row
2121

2222

@@ -796,6 +796,22 @@ def nextset(self) -> Union[bool, None]:
796796
return False
797797
return True
798798

799+
def __enter__(self):
800+
"""
801+
Enter the runtime context for the cursor.
802+
803+
Returns:
804+
The cursor instance itself.
805+
"""
806+
self._check_closed()
807+
return self
808+
809+
def __exit__(self, *args):
810+
"""Closes the cursor when exiting the context, ensuring proper resource cleanup."""
811+
if not self.closed:
812+
self.close()
813+
return None
814+
799815
def __del__(self):
800816
"""
801817
Destructor to ensure the cursor is closed when it is no longer needed.

tests/test_003_connection.py

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,12 +16,16 @@
1616
- test_connection_string_with_attrs_before: Check if the connection string is constructed correctly with attrs_before.
1717
- test_connection_string_with_odbc_param: Check if the connection string is constructed correctly with ODBC parameters.
1818
- test_rollback_on_close: Test that rollback occurs on connection close if autocommit is False.
19+
- test_context_manager_commit: Test that context manager commits transaction on normal exit.
20+
- test_context_manager_autocommit_mode: Test context manager behavior with autocommit enabled.
21+
- test_context_manager_connection_closes: Test that context manager closes the connection.
1922
"""
2023

2124
from mssql_python.exceptions import InterfaceError
2225
import pytest
2326
import time
2427
from mssql_python import Connection, connect, pooling
28+
from contextlib import closing
2529
import threading
2630

2731
# Import all exception classes for testing
@@ -500,6 +504,113 @@ def test_connection_pooling_basic(conn_str):
500504
conn1.close()
501505
conn2.close()
502506

507+
def test_context_manager_commit(conn_str):
508+
"""Test that context manager closes connection on normal exit"""
509+
# Create a permanent table for testing across connections
510+
setup_conn = connect(conn_str)
511+
setup_cursor = setup_conn.cursor()
512+
drop_table_if_exists(setup_cursor, "pytest_context_manager_test")
513+
514+
try:
515+
setup_cursor.execute("CREATE TABLE pytest_context_manager_test (id INT PRIMARY KEY, value VARCHAR(50));")
516+
setup_conn.commit()
517+
setup_conn.close()
518+
519+
# Test context manager closes connection
520+
with connect(conn_str) as conn:
521+
assert conn.autocommit is False, "Autocommit should be False by default"
522+
cursor = conn.cursor()
523+
cursor.execute("INSERT INTO pytest_context_manager_test (id, value) VALUES (1, 'context_test');")
524+
conn.commit() # Manual commit now required
525+
# Connection should be closed here
526+
527+
# Verify data was committed manually
528+
verify_conn = connect(conn_str)
529+
verify_cursor = verify_conn.cursor()
530+
verify_cursor.execute("SELECT * FROM pytest_context_manager_test WHERE id = 1;")
531+
result = verify_cursor.fetchone()
532+
assert result is not None, "Manual commit failed: No data found"
533+
assert result[1] == 'context_test', "Manual commit failed: Incorrect data"
534+
verify_conn.close()
535+
536+
except Exception as e:
537+
pytest.fail(f"Context manager test failed: {e}")
538+
finally:
539+
# Cleanup
540+
cleanup_conn = connect(conn_str)
541+
cleanup_cursor = cleanup_conn.cursor()
542+
drop_table_if_exists(cleanup_cursor, "pytest_context_manager_test")
543+
cleanup_conn.commit()
544+
cleanup_conn.close()
545+
546+
def test_context_manager_connection_closes(conn_str):
547+
"""Test that context manager closes the connection"""
548+
conn = None
549+
try:
550+
with connect(conn_str) as conn:
551+
cursor = conn.cursor()
552+
cursor.execute("SELECT 1")
553+
result = cursor.fetchone()
554+
assert result[0] == 1, "Connection should work inside context manager"
555+
556+
# Connection should be closed after exiting context manager
557+
assert conn._closed, "Connection should be closed after exiting context manager"
558+
559+
# Should not be able to use the connection after closing
560+
with pytest.raises(InterfaceError):
561+
conn.cursor()
562+
563+
except Exception as e:
564+
pytest.fail(f"Context manager connection close test failed: {e}")
565+
566+
def test_close_with_autocommit_true(conn_str):
567+
"""Test that connection.close() with autocommit=True doesn't trigger rollback."""
568+
cursor = None
569+
conn = None
570+
571+
try:
572+
# Create a temporary table for testing
573+
setup_conn = connect(conn_str)
574+
setup_cursor = setup_conn.cursor()
575+
drop_table_if_exists(setup_cursor, "pytest_autocommit_close_test")
576+
setup_cursor.execute("CREATE TABLE pytest_autocommit_close_test (id INT PRIMARY KEY, value VARCHAR(50));")
577+
setup_conn.commit()
578+
setup_conn.close()
579+
580+
# Create a connection with autocommit=True
581+
conn = connect(conn_str)
582+
conn.autocommit = True
583+
assert conn.autocommit is True, "Autocommit should be True"
584+
585+
# Insert data
586+
cursor = conn.cursor()
587+
cursor.execute("INSERT INTO pytest_autocommit_close_test (id, value) VALUES (1, 'test_autocommit');")
588+
589+
# Close the connection without explicitly committing
590+
conn.close()
591+
592+
# Verify the data was committed automatically despite connection.close()
593+
verify_conn = connect(conn_str)
594+
verify_cursor = verify_conn.cursor()
595+
verify_cursor.execute("SELECT * FROM pytest_autocommit_close_test WHERE id = 1;")
596+
result = verify_cursor.fetchone()
597+
598+
# Data should be present if autocommit worked and wasn't affected by close()
599+
assert result is not None, "Autocommit failed: Data not found after connection close"
600+
assert result[1] == 'test_autocommit', "Autocommit failed: Incorrect data after connection close"
601+
602+
verify_conn.close()
603+
604+
except Exception as e:
605+
pytest.fail(f"Test failed: {e}")
606+
finally:
607+
# Clean up
608+
cleanup_conn = connect(conn_str)
609+
cleanup_cursor = cleanup_conn.cursor()
610+
drop_table_if_exists(cleanup_cursor, "pytest_autocommit_close_test")
611+
cleanup_conn.commit()
612+
cleanup_conn.close()
613+
503614
# DB-API 2.0 Exception Attribute Tests
504615
def test_connection_exception_attributes_exist(db_connection):
505616
"""Test that all DB-API 2.0 exception classes are available as Connection attributes"""
@@ -684,3 +795,4 @@ def test_connection_exception_attributes_comprehensive_list():
684795
exc_class = getattr(Connection, exc_name)
685796
assert isinstance(exc_class, type), f"Connection.{exc_name} should be a class"
686797
assert issubclass(exc_class, Exception), f"Connection.{exc_name} should be an Exception subclass"
798+

0 commit comments

Comments
 (0)