diff --git a/mssql-py-core/src/async_connection.rs b/mssql-py-core/src/async_connection.rs index f19252f4..538ac2ae 100644 --- a/mssql-py-core/src/async_connection.rs +++ b/mssql-py-core/src/async_connection.rs @@ -3,23 +3,9 @@ //! Asynchronous connection API for the Core TDS backend. //! -//! # ⚠️ Preview API — unstable +//! Preview API — unstable. First use emits a `FutureWarning`. //! -//! The types and methods in this module are **not** part of the stable -//! `mssql-py-core` surface. Signatures, error behavior, and internal -//! semantics may change without notice in any release. First use in a -//! Python process emits a [`FutureWarning`] via `warnings.warn`. -//! -//! Sibling of `connection.rs` (the synchronous surface). Every type defined -//! here submits its I/O to the shared process-wide Tokio runtime via -//! [`crate::async_runtime`] and returns Python awaitables through -//! `pyo3_async_runtimes::tokio::future_into_py`, so callers can `await` the -//! results from `asyncio`. -//! -//! Invariant: one async connection maps to exactly one async cursor, one -//! `TdsClient`, and one TDS wire session. -//! -//! [`FutureWarning`]: https://docs.python.org/3/library/exceptions.html#FutureWarning +//! Invariant: one async connection ↔ one async cursor ↔ one `TdsClient`. use std::sync::Arc; use std::sync::atomic::{AtomicBool, Ordering}; @@ -34,9 +20,9 @@ use mssql_tds::connection_provider::tds_connection_provider::TdsConnectionProvid use crate::async_cursor::PyAsyncCursor; use crate::connection::PyCoreConnection; +use crate::python_logger_adapter::scoped_tracing_bridge; -/// Emit a `FutureWarning` the first time any async API is exercised in this -/// process. Silenceable by callers via `warnings.filterwarnings(...)`. +/// One-shot `FutureWarning` per process; silenceable via `warnings.filterwarnings`. static PREVIEW_WARNED: AtomicBool = AtomicBool::new(false); fn emit_preview_warning(py: Python<'_>) -> PyResult<()> { @@ -55,83 +41,84 @@ fn emit_preview_warning(py: Python<'_>) -> PyResult<()> { Ok(()) } -/// Asynchronous Python connection backed by the Core TDS client. -/// -/// # ⚠️ Preview API — unstable +/// Map a TDS error to a Python exception with per-operation context. /// -/// Preview surface: API, method signatures, error behavior, and internal -/// semantics may change without notice in minor releases. Do not depend on -/// it from production code. +/// TODO(User Story 47181): map TdsError to a DB-API-compliant exception, +/// preserving SQLSTATE + server error number. +/// +fn map_tds_error(op: &str, user_msg: &str, e: impl std::fmt::Display) -> PyErr { + tracing::error!("PyAsyncConnection::{op}: failed: {e}"); + PyRuntimeError::new_err(format!("{user_msg}: {e}")) +} + +/// Asynchronous Python connection backed by the Core TDS client. /// -/// Instances are created via [`PyAsyncConnection::connect`], which returns a -/// Python awaitable. The awaitable resolves on the caller's `asyncio` loop -/// once the TCP + TLS + login handshake has completed on the shared Tokio -/// runtime. +/// Preview API — unstable. /// /// TODO(User Story 47180 [mssql-python] Cancel API and Cancellation Bridge): /// cancellation of a suspended `commit`, `rollback`, or `close` future can -/// desync the TDS byte stream (bytes written, response not yet read), so a -/// subsequent operation on the same connection may read a stale response -/// and corrupt the wire. Callers must not cancel these awaitables against a -/// connection they intend to keep using. Cancellation-safe semantics are -/// tracked at -/// . +/// desync the TDS byte stream. Callers must not cancel these awaitables +/// against a connection they intend to keep using. +/// #[pyclass] pub struct PyAsyncConnection { - /// Wrapped in `Option` so `close()` can take ownership and drop the - /// client; wrapped in `Arc>` so the (upcoming) - /// async cursor and connection-level lifecycle methods can share access - /// across `.await` points without corrupting the TDS byte stream. + /// `Option` so `close()` can `take()`; `Arc>` for cursor sharing. tds_client: Option>>, + /// Default query timeout (seconds) applied to cursors created from this + /// connection. `0` = no timeout, per pyodbc/ODBC `SQL_ATTR_QUERY_TIMEOUT`. + /// Pure Python-side state — the setter performs no I/O. + default_query_timeout: u32, } #[pymethods] impl PyAsyncConnection { - /// Establish a TDS connection asynchronously. - /// - /// ```python - /// conn = await PyAsyncConnection.connect(client_context_dict) - /// ``` - /// - /// Dictionary parsing runs synchronously on the calling thread (it needs - /// the GIL). The network handshake is submitted to the shared Tokio - /// runtime and driven concurrently with the caller's asyncio loop. + /// Establish a TDS connection. Dict parsing is synchronous; the network + /// handshake runs on the shared Tokio runtime. #[classmethod] + #[pyo3(signature = (client_context_dict, python_logger=None))] fn connect<'py>( cls: &Bound<'py, PyType>, client_context_dict: &Bound<'_, PyDict>, + python_logger: Option<&Bound<'_, PyAny>>, ) -> PyResult> { let py = cls.py(); - // Preview API: emit a one-shot FutureWarning so callers see the - // instability signal at runtime. + // `DefaultGuard` is `!Send`, so its coverage ends when this method returns. + let _guard = python_logger + .map(|logger| scoped_tracing_bridge(Arc::new(logger.clone().unbind()), file!())); + emit_preview_warning(py)?; + tracing::info!("PyAsyncConnection::connect: initiating async connection"); + tracing::info!("PyAsyncConnection::connect: extracting client context"); let context = PyCoreConnection::dict_to_client_context(client_context_dict)?; let datasource = context.data_source.clone(); tracing::info!( - "PyAsyncConnection::connect: encryption mode={:?}, trust_server_certificate={}, host_name_in_cert={:?}", + "PyAsyncConnection::connect: encryption mode={:?}, trust_server_certificate={}, host_name_in_cert={:?}, server_certificate={:?}", context.encryption_options.mode, context.encryption_options.trust_server_certificate, context.encryption_options.host_name_in_cert, + context.encryption_options.server_certificate, + ); + + tracing::info!( + "PyAsyncConnection::connect: authentication method={:?}", + context.tds_authentication_method, + ); + + tracing::info!( + "PyAsyncConnection::connect: attempting connection to datasource: {}", + datasource ); pyo3_async_runtimes::tokio::future_into_py(py, async move { - tracing::info!( - "PyAsyncConnection::connect: opening TDS connection to {}", - datasource - ); let provider = TdsConnectionProvider {}; let client = provider .create_client(context, &datasource, None) .await - .map_err(|e| { - tracing::error!("PyAsyncConnection::connect: failed: {}", e); - // TODO(User Story 47181): map TdsError to a DB-API-compliant exception, preserving SQLSTATE + server error number. - PyRuntimeError::new_err(format!("Failed to connect to SQL Server: {e}")) - })?; + .map_err(|e| map_tds_error("connect", "Failed to connect to SQL Server", e))?; tracing::info!("PyAsyncConnection::connect: connection established"); Python::attach(|py| { @@ -139,31 +126,56 @@ impl PyAsyncConnection { py, PyAsyncConnection { tds_client: Some(Arc::new(Mutex::new(client))), + default_query_timeout: 0, }, ) }) }) } - /// Close the TDS connection asynchronously. - /// - /// ```python - /// await conn.close() - /// ``` - /// - /// Sends the TDS logout token and tears down the underlying transport. - /// The awaitable is submitted to the shared Tokio runtime so the calling - /// asyncio loop stays unblocked while the graceful shutdown runs. - /// - /// Idempotent: awaiting `close()` on an already-closed connection - /// resolves immediately with no I/O. If the graceful shutdown itself - /// errors, the error is logged at `warn` level and the connection is - /// still considered closed — the OS closes the socket on drop either - /// way, so we never leak the resource. + /// Default query timeout (seconds) inherited by cursors created from this + /// connection. `0` means no timeout. + #[getter] + fn timeout(&self) -> u32 { + self.default_query_timeout + } + + /// Set the default query timeout (seconds) for future cursors. Existing + /// cursors and in-flight queries are unaffected. Negative values are + /// rejected by PyO3's `u32` extractor (`OverflowError`). + #[setter] + fn set_timeout(&mut self, value: u32) { + tracing::info!( + "PyAsyncConnection::set_timeout: default query timeout set to {}s", + value + ); + self.default_query_timeout = value; + } + + /// True after `close()` has been awaited (or if `connect()` never produced a + /// live client). Cheap LBYL check; performs no I/O. + #[getter] + fn closed(&self) -> bool { + self.tds_client.is_none() + } + + /// Inverse of `.closed`, provided for sync-path parity with `PyCoreConnection`. + fn is_connected(&self) -> bool { + self.tds_client.is_some() + } + + fn __repr__(&self) -> &'static str { + if self.tds_client.is_none() { + "PyAsyncConnection(closed)" + } else { + "PyAsyncConnection(connected)" + } + } + + /// Close the connection. Idempotent. Shutdown errors are logged and swallowed. fn close<'py>(&mut self, py: Python<'py>) -> PyResult> { - // Detach the client from `self` synchronously (while `&mut self` is - // valid) so the future can own it for `'static + Send`. Subsequent - // method calls on this connection will see `tds_client == None`. + tracing::info!("PyAsyncConnection::close: initiating close"); + // `take()` before spawning: gives the future 'static ownership; marks conn closed. let client_opt = self.tds_client.take(); pyo3_async_runtimes::tokio::future_into_py(py, async move { @@ -177,35 +189,38 @@ impl PyAsyncConnection { ); let mut guard = client.lock().await; if let Err(e) = guard.close_connection().await { - // Match sync-path semantics: log and swallow. The connection - // is treated as closed regardless — the transport will be - // dropped when the Arc's last reference goes away. + // Log and swallow; connection is closed regardless. tracing::warn!( "PyAsyncConnection::close: error during graceful shutdown: {}", e ); } + tracing::info!("PyAsyncConnection::close: connection closed"); Python::attach(|py| Ok(py.None())) }) } - /// Commit the current TDS transaction asynchronously. - /// - /// ```python - /// await conn.commit() - /// ``` - /// - /// Sends a TM_COMMIT (Transaction Manager COMMIT) request over the wire - /// and awaits the server's DONE token. Raises `RuntimeError` - /// synchronously if the connection has already been closed. - /// - /// If no transaction is currently open on the server, the commit will - /// fail with the server's own error (SQL Server 3902 — "The COMMIT - /// TRANSACTION request has no corresponding BEGIN TRANSACTION"). + /// Async context manager entry. Resolves to `self` with no I/O. + fn __aenter__<'py>(slf: Py, py: Python<'py>) -> PyResult> { + pyo3_async_runtimes::tokio::future_into_py(py, async move { Ok(slf) }) + } + + /// Async context manager exit. Delegates to `close()`; exception info is + /// ignored and never suppressed (`close()` resolves to `None`, which is + /// falsy per Python's `__aexit__` contract). + fn __aexit__<'py>( + &mut self, + py: Python<'py>, + _exc_type: &Bound<'_, PyAny>, + _exc_val: &Bound<'_, PyAny>, + _exc_tb: &Bound<'_, PyAny>, + ) -> PyResult> { + self.close(py) + } + + /// Commit the current transaction. If none is open, surfaces SQL Server 3902. fn commit<'py>(&self, py: Python<'py>) -> PyResult> { - // Clone the Arc synchronously so the future is `'static + Send` - // without borrowing `self`. Only a shared borrow is required — - // nothing on `self` is mutated here. + // Clone the Arc so the future is `'static + Send`. let client = self .tds_client .as_ref() @@ -215,33 +230,18 @@ impl PyAsyncConnection { pyo3_async_runtimes::tokio::future_into_py(py, async move { tracing::info!("PyAsyncConnection::commit: sending TM_COMMIT"); let mut guard = client.lock().await; - guard.commit_transaction(None, None).await.map_err(|e| { - tracing::error!("PyAsyncConnection::commit: failed: {}", e); - // TODO(User Story 47181): map TdsError to a DB-API-compliant exception, preserving SQLSTATE + server error number. - PyRuntimeError::new_err(format!("Commit failed: {e}")) - })?; + guard + .commit_transaction(None, None) + .await + .map_err(|e| map_tds_error("commit", "Commit failed", e))?; tracing::info!("PyAsyncConnection::commit: transaction committed"); Python::attach(|py| Ok(py.None())) }) } - /// Roll back the current TDS transaction asynchronously. - /// - /// ```python - /// await conn.rollback() - /// ``` - /// - /// Sends a TM_ROLLBACK (Transaction Manager ROLLBACK) request over the - /// wire and awaits the server's DONE token. Raises `RuntimeError` - /// synchronously if the connection has already been closed. - /// - /// If no transaction is currently open on the server, the rollback will - /// fail with the server's own error (SQL Server 3903 — "The ROLLBACK - /// TRANSACTION request has no corresponding BEGIN TRANSACTION"). + /// Roll back the current transaction. If none is open, surfaces SQL Server 3903. fn rollback<'py>(&self, py: Python<'py>) -> PyResult> { - // Clone the Arc synchronously so the future is `'static + Send` - // without borrowing `self`. Only a shared borrow is required — - // nothing on `self` is mutated here. + // Clone the Arc so the future is `'static + Send`. let client = self .tds_client .as_ref() @@ -251,39 +251,23 @@ impl PyAsyncConnection { pyo3_async_runtimes::tokio::future_into_py(py, async move { tracing::info!("PyAsyncConnection::rollback: sending TM_ROLLBACK"); let mut guard = client.lock().await; - guard.rollback_transaction(None, None).await.map_err(|e| { - tracing::error!("PyAsyncConnection::rollback: failed: {}", e); - // TODO(User Story 47181): map TdsError to a DB-API-compliant exception, preserving SQLSTATE + server error number. - PyRuntimeError::new_err(format!("Rollback failed: {e}")) - })?; + guard + .rollback_transaction(None, None) + .await + .map_err(|e| map_tds_error("rollback", "Rollback failed", e))?; tracing::info!("PyAsyncConnection::rollback: transaction rolled back"); Python::attach(|py| Ok(py.None())) }) } - /// Create an async cursor bound to this connection. - /// - /// ```python - /// cur = conn.cursor() - /// await cur.execute("SELECT 1") - /// ``` - /// - /// This method does not perform I/O — it simply hands out a new - /// [`PyAsyncCursor`] that shares the connection's `TdsClient` via an - /// `Arc>`. Following DB-API 2.0, `cursor()` is a - /// synchronous call; only the cursor's execute/fetch methods will be - /// awaitable. - /// - /// Raises `RuntimeError` if the connection has already been closed. - /// A second cursor may be created on the same connection, but both - /// cursors share one TDS wire session and serialize on the same async - /// mutex — matching the non-MARS TDS session model. + /// Sync per DB-API 2.0. A second cursor is allowed; both share the same + /// TDS session and serialize on the same async mutex. fn cursor(&self) -> PyResult { let client = self .tds_client .as_ref() .ok_or_else(|| PyRuntimeError::new_err("Connection is closed"))? .clone(); - Ok(PyAsyncCursor::new(client)) + Ok(PyAsyncCursor::new(client, self.default_query_timeout)) } } diff --git a/mssql-py-core/src/async_cursor.rs b/mssql-py-core/src/async_cursor.rs index 116b5193..337ee10f 100644 --- a/mssql-py-core/src/async_cursor.rs +++ b/mssql-py-core/src/async_cursor.rs @@ -48,14 +48,22 @@ pub struct PyAsyncCursor { /// serializes wire access across `.await` points. #[allow(dead_code)] // Consumed by upcoming async execute/fetch/close APIs. tds_client: Arc>, + /// Snapshot of the parent connection's default query timeout at + /// `cursor()` time (`0` = no timeout). Applied by the future `execute` + /// path unless overridden per-call. + #[allow(dead_code)] // Consumed by upcoming async execute API. + default_query_timeout: u32, } impl PyAsyncCursor { /// Construct a new cursor bound to the given TDS client. /// /// Called only from `PyAsyncConnection::cursor`. - pub(crate) fn new(tds_client: Arc>) -> Self { - Self { tds_client } + pub(crate) fn new(tds_client: Arc>, default_query_timeout: u32) -> Self { + Self { + tds_client, + default_query_timeout, + } } } diff --git a/mssql-py-core/tests/test_async_connection.py b/mssql-py-core/tests/test_async_connection.py index 55d6c554..e6254d69 100644 --- a/mssql-py-core/tests/test_async_connection.py +++ b/mssql-py-core/tests/test_async_connection.py @@ -1,7 +1,8 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. -"""Tests for PyAsyncConnection: connect, close, commit, rollback, cursor.""" +"""Tests for PyAsyncConnection: connect, close, commit, rollback, cursor, +timeout, lifecycle state (closed/is_connected), async context manager, repr.""" import asyncio import subprocess @@ -75,8 +76,8 @@ async def run(): # --------------------------------------------------------------------------- @pytest.mark.integration -def test_close_is_awaitable(client_context): - """close() returns an awaitable that resolves to None.""" +def test_close_resolves_to_none(client_context): + """Regression guard: close() awaitable must resolve to None, not empty tuple.""" async def run(): with warnings.catch_warnings(): warnings.simplefilter("ignore", FutureWarning) @@ -87,35 +88,20 @@ async def run(): asyncio.run(run()) -@pytest.mark.integration -def test_close_is_idempotent(client_context): - """Awaiting close() twice does not raise.""" - async def run(): - with warnings.catch_warnings(): - warnings.simplefilter("ignore", FutureWarning) - conn = await mssql_py_core.PyAsyncConnection.connect(client_context) - await conn.close() - await conn.close() # no-op path (tds_client is None) - - asyncio.run(run()) - - # --------------------------------------------------------------------------- # Commit / Rollback # --------------------------------------------------------------------------- @pytest.mark.integration -def test_commit_returns_awaitable_that_resolves(client_context): - """commit() with no active transaction always raises SQL Server 3902.""" +def test_commit_without_active_transaction_raises_3902(client_context): + """PyAsyncConnection has no begin_transaction; TM_COMMIT deterministically + yields SQL Server error 3902. Matching the server error number keeps this + valid after the DB-API error taxonomy lands.""" async def run(): with warnings.catch_warnings(): warnings.simplefilter("ignore", FutureWarning) conn = await mssql_py_core.PyAsyncConnection.connect(client_context) try: - # PyAsyncConnection has no begin_transaction, so a fresh - # connection has no open TDS transaction; TM_COMMIT deterministically - # yields SQL Server 3902. Matching the server error number keeps - # this valid after the DB-API error taxonomy lands. with pytest.raises(Exception, match="3902"): await conn.commit() finally: @@ -125,15 +111,13 @@ async def run(): @pytest.mark.integration -def test_rollback_returns_awaitable_that_resolves(client_context): - """rollback() with no active transaction always raises SQL Server 3903.""" +def test_rollback_without_active_transaction_raises_3903(client_context): + """Same rationale as commit: TM_ROLLBACK deterministically yields 3903.""" async def run(): with warnings.catch_warnings(): warnings.simplefilter("ignore", FutureWarning) conn = await mssql_py_core.PyAsyncConnection.connect(client_context) try: - # Same rationale as commit: fresh connection has no open TDS - # transaction; TM_ROLLBACK deterministically yields 3903. with pytest.raises(Exception, match="3903"): await conn.rollback() finally: @@ -168,3 +152,199 @@ async def run(): await conn.rollback() asyncio.run(run()) + + +# --------------------------------------------------------------------------- +# timeout getter/setter (default query timeout for cursors; 0 = no timeout) +# --------------------------------------------------------------------------- + +@pytest.mark.integration +def test_timeout_default_and_setter_roundtrip(client_context): + """Default is 0 (pyodbc/ODBC convention: no timeout); setter roundtrips.""" + async def run(): + with warnings.catch_warnings(): + warnings.simplefilter("ignore", FutureWarning) + conn = await mssql_py_core.PyAsyncConnection.connect(client_context) + try: + assert conn.timeout == 0 + conn.timeout = 30 + assert conn.timeout == 30 + conn.timeout = 0 + assert conn.timeout == 0 + finally: + await conn.close() + + asyncio.run(run()) + + +@pytest.mark.integration +def test_timeout_setter_rejects_negative(client_context): + """Negative values overflow the u32 extractor and raise OverflowError.""" + async def run(): + with warnings.catch_warnings(): + warnings.simplefilter("ignore", FutureWarning) + conn = await mssql_py_core.PyAsyncConnection.connect(client_context) + try: + with pytest.raises(OverflowError): + conn.timeout = -1 + assert conn.timeout == 0 + finally: + await conn.close() + + asyncio.run(run()) + + +# --------------------------------------------------------------------------- +# Lifecycle state: closed (property) + is_connected() + idempotency +# --------------------------------------------------------------------------- + +@pytest.mark.integration +def test_lifecycle_state_reflects_closed_and_is_connected(client_context): + """closed and is_connected() are inverses at both live and closed states; + close() is idempotent — a second close keeps closed=True.""" + async def run(): + with warnings.catch_warnings(): + warnings.simplefilter("ignore", FutureWarning) + conn = await mssql_py_core.PyAsyncConnection.connect(client_context) + # Live state. + assert conn.closed is False + assert conn.is_connected() is True + assert conn.is_connected() is (not conn.closed) + # First close transitions to closed. + await conn.close() + assert conn.closed is True + assert conn.is_connected() is False + assert conn.is_connected() is (not conn.closed) + # Idempotent close keeps state. + await conn.close() + assert conn.closed is True + + asyncio.run(run()) + + +# --------------------------------------------------------------------------- +# __aenter__ / __aexit__ — async context manager +# --------------------------------------------------------------------------- + +@pytest.mark.integration +def test_async_context_manager_closes_on_exit(client_context): + """`async with` awaits close() on exit; conn.closed becomes True.""" + async def run(): + with warnings.catch_warnings(): + warnings.simplefilter("ignore", FutureWarning) + conn_ref = None + async with await mssql_py_core.PyAsyncConnection.connect(client_context) as conn: + conn_ref = conn + assert conn.closed is False + assert conn_ref.closed is True + + asyncio.run(run()) + + +@pytest.mark.integration +def test_async_context_manager_yields_same_object(client_context): + """__aenter__ resolves to `self` — the same PyAsyncConnection instance.""" + async def run(): + with warnings.catch_warnings(): + warnings.simplefilter("ignore", FutureWarning) + outer = await mssql_py_core.PyAsyncConnection.connect(client_context) + try: + async with outer as inner: + assert inner is outer + finally: + if not outer.closed: + await outer.close() + + asyncio.run(run()) + + +@pytest.mark.integration +def test_async_context_manager_propagates_exception_and_still_closes(client_context): + """Exception inside the block propagates AND the connection is closed.""" + class Boom(RuntimeError): + pass + + async def run(): + with warnings.catch_warnings(): + warnings.simplefilter("ignore", FutureWarning) + conn_ref = None + with pytest.raises(Boom, match="kaboom"): + async with await mssql_py_core.PyAsyncConnection.connect(client_context) as conn: + conn_ref = conn + raise Boom("kaboom") + assert conn_ref is not None + assert conn_ref.closed is True + + asyncio.run(run()) + + +# --------------------------------------------------------------------------- +# __repr__ — introspection +# --------------------------------------------------------------------------- + +@pytest.mark.integration +def test_repr_reflects_lifecycle(client_context): + """repr flips from 'PyAsyncConnection(connected)' to '(closed)' after close.""" + async def run(): + with warnings.catch_warnings(): + warnings.simplefilter("ignore", FutureWarning) + conn = await mssql_py_core.PyAsyncConnection.connect(client_context) + assert repr(conn) == "PyAsyncConnection(connected)" + await conn.close() + assert repr(conn) == "PyAsyncConnection(closed)" + + asyncio.run(run()) + + +# --------------------------------------------------------------------------- +# cursor() — sync method returning PyAsyncCursor +# --------------------------------------------------------------------------- + +@pytest.mark.integration +def test_cursor_returns_pyasynccursor(client_context): + """cursor() on a live connection returns a PyAsyncCursor instance.""" + async def run(): + with warnings.catch_warnings(): + warnings.simplefilter("ignore", FutureWarning) + conn = await mssql_py_core.PyAsyncConnection.connect(client_context) + try: + cur = conn.cursor() + assert isinstance(cur, mssql_py_core.PyAsyncCursor) + finally: + await conn.close() + + asyncio.run(run()) + + +@pytest.mark.integration +def test_cursor_after_close_raises_connection_closed(client_context): + """cursor() on a closed connection raises RuntimeError synchronously.""" + async def run(): + with warnings.catch_warnings(): + warnings.simplefilter("ignore", FutureWarning) + conn = await mssql_py_core.PyAsyncConnection.connect(client_context) + await conn.close() + with pytest.raises(RuntimeError, match="Connection is closed"): + conn.cursor() + + asyncio.run(run()) + + +@pytest.mark.integration +def test_cursor_can_be_created_multiple_times(client_context): + """Per module invariant, a connection may issue multiple cursors; both + share the same TdsClient and serialize on the same async mutex.""" + async def run(): + with warnings.catch_warnings(): + warnings.simplefilter("ignore", FutureWarning) + conn = await mssql_py_core.PyAsyncConnection.connect(client_context) + try: + cur1 = conn.cursor() + cur2 = conn.cursor() + assert cur1 is not cur2 + assert isinstance(cur1, mssql_py_core.PyAsyncCursor) + assert isinstance(cur2, mssql_py_core.PyAsyncCursor) + finally: + await conn.close() + + asyncio.run(run())