Skip to content

Commit fff2faf

Browse files
Stop cursor methods from over-clearing Connection.messages per PEP 249
PEP 249 §6.1.1 says ``Connection.messages`` is cleared by *connection* methods (cursor() / commit() / rollback() / close()); §6.1.2 says ``Cursor.messages`` is cleared by *cursor* methods. The two surfaces are independent. Every secondary cursor method (``fetchone`` / ``fetchmany`` / ``fetchall`` / ``setinputsizes`` / ``setoutputsize`` / ``callproc`` / ``nextset`` / ``scroll``) on both the sync ``Cursor`` and async ``AsyncCursor`` was clearing ``Connection.messages`` in addition to its own ``Cursor.messages`` — defeating the independent-surface contract. A sibling-cursor or direct-connection inspection of ``connection.messages`` after a cursor call always saw ``[]`` regardless of the connection's actual diagnostic state. Remove the 15 over-clear blocks (9 sync, 6 async) and the misciting "PEP 249 §6.1.1 — Connection.messages is cleared by the cursor fetch methods" comments that misread the spec. Update the existing tests that pinned the wrong behaviour to pin the corrected independence contract instead. Add a dedicated independence pin module covering the full surface (fetch* / setinputsizes / setoutputsize / callproc / nextset / scroll, plus the existing §6.1.2 ``Cursor.messages`` clear-on-cursor-methods contract). Behaviour is invisible today (both lists are populated nowhere — see cycle-28-dropped/MS1) but the cross-surface read contract is fixed for any future code path that begins populating either list, and the comments now accurately cite the spec. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 5442223 commit fff2faf

8 files changed

Lines changed: 158 additions & 105 deletions

src/dqlitedbapi/aio/cursor.py

Lines changed: 0 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -473,11 +473,6 @@ async def fetchone(self) -> tuple[Any, ...] | None:
473473
# already adopted.
474474
self._connection._check_loop_binding()
475475
self._check_result_set()
476-
# PEP 249 §6.1.1 — Connection.messages is cleared by the
477-
# cursor fetch methods. Defensive against test mocks.
478-
conn_messages = getattr(self._connection, "messages", None)
479-
if conn_messages is not None:
480-
del conn_messages[:]
481476

482477
if self._row_index >= len(self._rows):
483478
return None
@@ -497,11 +492,6 @@ async def fetchmany(self, size: int | None = None) -> list[tuple[Any, ...]]:
497492
# Loop-binding check; see ``fetchone`` rationale.
498493
self._connection._check_loop_binding()
499494
self._check_result_set()
500-
# PEP 249 §6.1.1 — Connection.messages is cleared by the
501-
# cursor fetch methods. Defensive against test mocks.
502-
conn_messages = getattr(self._connection, "messages", None)
503-
if conn_messages is not None:
504-
del conn_messages[:]
505495

506496
if size is None:
507497
size = self._arraysize
@@ -542,11 +532,6 @@ async def fetchall(self) -> list[tuple[Any, ...]]:
542532
# Loop-binding check; see ``fetchone`` rationale.
543533
self._connection._check_loop_binding()
544534
self._check_result_set()
545-
# PEP 249 §6.1.1 — Connection.messages is cleared by the
546-
# cursor fetch methods. Defensive against test mocks.
547-
conn_messages = getattr(self._connection, "messages", None)
548-
if conn_messages is not None:
549-
del conn_messages[:]
550535

551536
result = self._rows[self._row_index :]
552537
self._row_index = len(self._rows)
@@ -596,9 +581,6 @@ def setinputsizes(self, sizes: Sequence[Any]) -> None:
596581
# PEP 249 §6.1.1 — clear "prior to executing the call" so the
597582
# contract holds even on the cross-loop rejection path.
598583
del self.messages[:]
599-
conn_messages = getattr(self._connection, "messages", None)
600-
if conn_messages is not None:
601-
del conn_messages[:]
602584
# PEP 249 §6.2 says implementations are "free to have this
603585
# method do nothing" — including on closed cursors. Skip
604586
# the closed-cursor check.
@@ -614,9 +596,6 @@ def setinputsizes(self, sizes: Sequence[Any]) -> None:
614596
def setoutputsize(self, size: int, column: int | None = None) -> None:
615597
"""Set output size (no-op for dqlite). See ``setinputsizes``."""
616598
del self.messages[:]
617-
conn_messages = getattr(self._connection, "messages", None)
618-
if conn_messages is not None:
619-
del conn_messages[:]
620599
# PEP 249 §6.2 — see ``setinputsizes`` rationale.
621600
self._connection._check_loop_binding()
622601

@@ -635,9 +614,6 @@ def callproc(self, procname: str, parameters: Sequence[Any] | None = None) -> No
635614
# Clear before any guard so the contract holds even on the
636615
# closed-cursor / cross-loop / not-supported paths.
637616
del self.messages[:]
638-
conn_messages = getattr(self._connection, "messages", None)
639-
if conn_messages is not None:
640-
del conn_messages[:]
641617
# PEP 249 §6.1.2 — closed-cursor ops raise.
642618
self._check_closed()
643619
# Loop-binding check: parallel to the sync side's
@@ -653,9 +629,6 @@ def nextset(self) -> NoReturn:
653629
"""PEP 249 optional extension — not supported."""
654630
# PEP 249 §6.1.1 — clear before any guard.
655631
del self.messages[:]
656-
conn_messages = getattr(self._connection, "messages", None)
657-
if conn_messages is not None:
658-
del conn_messages[:]
659632
# PEP 249 §6.1.2 — closed-cursor ops raise.
660633
self._check_closed()
661634
# Loop-binding check; see ``callproc`` for rationale. Use
@@ -675,9 +648,6 @@ def scroll(self, value: int, mode: str = "relative") -> NoReturn:
675648
# ``messages`` cannot leave stale entries visible after the
676649
# caller observed the rejection. Clear before any guard.
677650
del self.messages[:]
678-
conn_messages = getattr(self._connection, "messages", None)
679-
if conn_messages is not None:
680-
del conn_messages[:]
681651
# PEP 249 §6.1.2 — closed-cursor ops raise.
682652
self._check_closed()
683653
# Loop-binding check; see ``callproc`` for rationale. Use
@@ -705,9 +675,6 @@ def executescript(self, sql_script: str) -> NoReturn:
705675
diagnostic-leak prevention this stub family was added for.
706676
"""
707677
del self.messages[:]
708-
conn_messages = getattr(self._connection, "messages", None)
709-
if conn_messages is not None:
710-
del conn_messages[:]
711678
self._check_closed()
712679
self._connection._check_loop_binding()
713680
raise NotSupportedError(

src/dqlitedbapi/cursor.py

Lines changed: 0 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -1202,14 +1202,6 @@ def fetchone(self) -> tuple[Any, ...] | None:
12021202
self._connection._check_thread()
12031203
self._check_closed()
12041204
self._check_result_set()
1205-
# PEP 249 §6.1.1 — ``Connection.messages`` is cleared by the
1206-
# cursor fetch methods. Done after the closed-check so a
1207-
# closed cursor's fetch raises ``InterfaceError`` without
1208-
# first perturbing the connection's diagnostic list. Defensive
1209-
# against test mocks that pre-date the PEP 249 messages surface.
1210-
conn_messages = getattr(self._connection, "messages", None)
1211-
if conn_messages is not None:
1212-
del conn_messages[:]
12131205

12141206
if self._row_index >= len(self._rows):
12151207
return None
@@ -1228,12 +1220,6 @@ def fetchmany(self, size: int | None = None) -> list[tuple[Any, ...]]:
12281220
self._connection._check_thread()
12291221
self._check_closed()
12301222
self._check_result_set()
1231-
# PEP 249 §6.1.1 — clear Connection.messages too. Defensive
1232-
# against test mocks that pre-date the PEP 249 messages
1233-
# surface.
1234-
conn_messages = getattr(self._connection, "messages", None)
1235-
if conn_messages is not None:
1236-
del conn_messages[:]
12371223

12381224
if size is None:
12391225
size = self._arraysize
@@ -1271,12 +1257,6 @@ def fetchall(self) -> list[tuple[Any, ...]]:
12711257
self._connection._check_thread()
12721258
self._check_closed()
12731259
self._check_result_set()
1274-
# PEP 249 §6.1.1 — clear Connection.messages too. Defensive
1275-
# against test mocks that pre-date the PEP 249 messages
1276-
# surface.
1277-
conn_messages = getattr(self._connection, "messages", None)
1278-
if conn_messages is not None:
1279-
del conn_messages[:]
12801260

12811261
result = self._rows[self._row_index :]
12821262
self._row_index = len(self._rows)
@@ -1359,9 +1339,6 @@ def setinputsizes(self, sizes: Sequence[Any]) -> None:
13591339
# ``_check_thread`` for the same reason; this method and its
13601340
# four secondary-method siblings keep the same ordering.
13611341
del self.messages[:]
1362-
conn_messages = getattr(self._connection, "messages", None)
1363-
if conn_messages is not None:
1364-
del conn_messages[:]
13651342
self._connection._check_thread()
13661343
# PEP 249 §6.2 says implementations are "free to have this
13671344
# method do nothing" — including on closed cursors. Skip the
@@ -1371,9 +1348,6 @@ def setinputsizes(self, sizes: Sequence[Any]) -> None:
13711348
def setoutputsize(self, size: int, column: int | None = None) -> None:
13721349
"""Set output size (no-op for dqlite). See ``setinputsizes``."""
13731350
del self.messages[:]
1374-
conn_messages = getattr(self._connection, "messages", None)
1375-
if conn_messages is not None:
1376-
del conn_messages[:]
13771351
self._connection._check_thread()
13781352
# PEP 249 §6.2 — see ``setinputsizes`` rationale.
13791353

@@ -1397,9 +1371,6 @@ def callproc(self, procname: str, parameters: Sequence[Any] | None = None) -> No
13971371
# Clear before any guard so the contract holds even on the
13981372
# cross-thread-rejection path. Mirrors ``nextset`` below.
13991373
del self.messages[:]
1400-
conn_messages = getattr(self._connection, "messages", None)
1401-
if conn_messages is not None:
1402-
del conn_messages[:]
14031374
self._connection._check_thread()
14041375
# PEP 249 §6.1.2 — closed-cursor ops raise. Order: check
14051376
# closed-state before raising NotSupported so the diagnostic
@@ -1417,9 +1388,6 @@ def nextset(self) -> NoReturn:
14171388
# so the contract holds even on the cross-thread-rejection
14181389
# path.
14191390
del self.messages[:]
1420-
conn_messages = getattr(self._connection, "messages", None)
1421-
if conn_messages is not None:
1422-
del conn_messages[:]
14231391
self._connection._check_thread()
14241392
# PEP 249 §6.1.2 — closed-cursor operations raise.
14251393
self._check_closed()
@@ -1438,9 +1406,6 @@ def scroll(self, value: int, mode: str = "relative") -> NoReturn:
14381406
# future code that starts populating ``messages``. Order
14391407
# matches the secondary-method family: clear before any guard.
14401408
del self.messages[:]
1441-
conn_messages = getattr(self._connection, "messages", None)
1442-
if conn_messages is not None:
1443-
del conn_messages[:]
14441409
self._connection._check_thread()
14451410
self._check_closed()
14461411
# PEP 249 §6.1.1 enumerates ``mode`` ∈ {"relative", "absolute"}.
@@ -1460,9 +1425,6 @@ def executescript(self, sql_script: str) -> NoReturn:
14601425
as ``AttributeError``. Same shape as the
14611426
``Connection.executescript`` stub."""
14621427
del self.messages[:]
1463-
conn_messages = getattr(self._connection, "messages", None)
1464-
if conn_messages is not None:
1465-
del conn_messages[:]
14661428
self._connection._check_thread()
14671429
self._check_closed()
14681430
raise NotSupportedError(

tests/test_async_secondary_methods_clear_messages_before_guards.py

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -46,9 +46,10 @@ def _expect_messages_cleared_after_closed_call(invoke: Callable[[Any], None], cu
4646
with pytest.raises(InterfaceError, match="closed"):
4747
invoke(cur)
4848
assert list(cur.messages) == [], "Cursor.messages must be cleared before _check_closed raises"
49-
assert list(cur._connection.messages) == [], (
50-
"Connection.messages must be cleared before _check_closed raises"
51-
)
49+
# PEP 249 §6.1.1 / §6.1.2 — Connection.messages and Cursor.messages
50+
# are independent surfaces. Cursor methods must NOT clear
51+
# Connection.messages.
52+
assert list(cur._connection.messages) == [(Warning, "stale-conn")]
5253

5354

5455
def _drive_other_loop(invoke_async: Callable[[], Any]) -> list[BaseException]:
@@ -83,7 +84,9 @@ async def test_setinputsizes_closed_cursor_clears_messages_first() -> None:
8384
cur._closed = True
8485
cur.setinputsizes([None])
8586
assert list(cur.messages) == []
86-
assert list(cur._connection.messages) == []
87+
# Connection.messages is the connection's surface; cursor methods
88+
# must not clear it (PEP 249 §6.1.1 / §6.1.2 independent surfaces).
89+
assert list(cur._connection.messages) == [(Warning, "stale-conn")]
8790

8891

8992
@pytest.mark.asyncio
@@ -95,7 +98,9 @@ async def test_setoutputsize_closed_cursor_clears_messages_first() -> None:
9598
cur._closed = True
9699
cur.setoutputsize(64)
97100
assert list(cur.messages) == []
98-
assert list(cur._connection.messages) == []
101+
# Connection.messages is the connection's surface; cursor methods
102+
# must not clear it (PEP 249 §6.1.1 / §6.1.2 independent surfaces).
103+
assert list(cur._connection.messages) == [(Warning, "stale-conn")]
99104

100105

101106
@pytest.mark.asyncio
@@ -137,4 +142,6 @@ async def test_callproc_cross_loop_clears_messages_first() -> None:
137142
f"expected ProgrammingError from cross-loop call; got {errors!r}"
138143
)
139144
assert list(cur.messages) == []
140-
assert list(cur._connection.messages) == []
145+
# Connection.messages is the connection's surface; cursor methods
146+
# must not clear it (PEP 249 §6.1.1 / §6.1.2 independent surfaces).
147+
assert list(cur._connection.messages) == [(Warning, "stale-conn")]

tests/test_callproc_scroll_clear_messages.py

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -29,16 +29,20 @@ def test_sync_callproc_clears_messages(cursor) -> None:
2929
cur, conn = cursor
3030
with pytest.raises(NotSupportedError):
3131
cur.callproc("p")
32-
assert list(conn.messages) == []
32+
# PEP 249 §6.1.1 / §6.1.2 — Connection.messages and Cursor.messages
33+
# are independent surfaces. Cursor methods clear only Cursor.messages.
3334
assert list(cur.messages) == []
35+
assert list(conn.messages) == [(Warning, "stale")]
3436

3537

3638
def test_sync_scroll_clears_messages(cursor) -> None:
3739
cur, conn = cursor
3840
with pytest.raises(NotSupportedError):
3941
cur.scroll(1)
40-
assert list(conn.messages) == []
42+
# PEP 249 §6.1.1 / §6.1.2 — Connection.messages and Cursor.messages
43+
# are independent surfaces. Cursor methods clear only Cursor.messages.
4144
assert list(cur.messages) == []
45+
assert list(conn.messages) == [(Warning, "stale")]
4246

4347

4448
@pytest.mark.asyncio
@@ -54,8 +58,10 @@ async def test_async_callproc_clears_messages() -> None:
5458
cur.messages.append((Warning, "stale"))
5559
with pytest.raises(NotSupportedError):
5660
cur.callproc("p")
57-
assert list(conn.messages) == []
61+
# PEP 249 §6.1.1 / §6.1.2 — Connection.messages and Cursor.messages
62+
# are independent surfaces. Cursor methods clear only Cursor.messages.
5863
assert list(cur.messages) == []
64+
assert list(conn.messages) == [(Warning, "stale")]
5965

6066

6167
@pytest.mark.asyncio
@@ -68,5 +74,7 @@ async def test_async_scroll_clears_messages() -> None:
6874
cur.messages.append((Warning, "stale"))
6975
with pytest.raises(NotSupportedError):
7076
cur.scroll(1)
71-
assert list(conn.messages) == []
77+
# PEP 249 §6.1.1 / §6.1.2 — Connection.messages and Cursor.messages
78+
# are independent surfaces. Cursor methods clear only Cursor.messages.
7279
assert list(cur.messages) == []
80+
assert list(conn.messages) == [(Warning, "stale")]

tests/test_connection_messages_cleared_by_cursor_methods.py

Lines changed: 30 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,16 @@
1-
"""PEP 249 §6.1.1 — Connection.messages is cleared by the cursor
2-
fetch methods (fetchone, fetchmany, fetchall) and by nextset.
1+
"""Pin: cursor methods do NOT clear ``Connection.messages``. PEP 249
2+
§6.1.1 says ``Connection.messages`` is cleared by *connection*
3+
methods (cursor() / commit() / rollback() / close()); §6.1.2 says
4+
``Cursor.messages`` is cleared by *cursor* methods. The two surfaces
5+
are independent.
6+
7+
Previously cursor methods over-cleared ``Connection.messages`` from
8+
inside ``fetchone`` / ``fetchmany`` / ``fetchall`` / ``nextset`` /
9+
``setinputsizes`` / ``setoutputsize`` / ``callproc`` / ``scroll``.
10+
That violated PEP 249's independent-surface contract — a sibling-
11+
cursor / direct-connection inspection of ``connection.messages``
12+
after a cursor call always saw ``[]``. Pin the corrected behaviour:
13+
cursor methods leave ``Connection.messages`` untouched.
314
"""
415

516
from __future__ import annotations
@@ -23,28 +34,32 @@ def _make_cursor() -> Cursor:
2334
return cur
2435

2536

26-
class TestConnectionMessagesCleared:
27-
def test_fetchone_clears_connection_messages(self) -> None:
37+
class TestConnectionMessagesNotClearedByCursorMethods:
38+
def test_fetchone_does_not_clear_connection_messages(self) -> None:
2839
cur = _make_cursor()
29-
cur._connection.messages.append((RuntimeError, "stale"))
40+
seed = (RuntimeError, "session-level diagnostic")
41+
cur._connection.messages.append(seed)
3042
cur.fetchone()
31-
assert cur._connection.messages == []
43+
assert cur._connection.messages == [seed]
3244

33-
def test_fetchmany_clears_connection_messages(self) -> None:
45+
def test_fetchmany_does_not_clear_connection_messages(self) -> None:
3446
cur = _make_cursor()
35-
cur._connection.messages.append((RuntimeError, "stale"))
47+
seed = (RuntimeError, "session-level diagnostic")
48+
cur._connection.messages.append(seed)
3649
cur.fetchmany(1)
37-
assert cur._connection.messages == []
50+
assert cur._connection.messages == [seed]
3851

39-
def test_fetchall_clears_connection_messages(self) -> None:
52+
def test_fetchall_does_not_clear_connection_messages(self) -> None:
4053
cur = _make_cursor()
41-
cur._connection.messages.append((RuntimeError, "stale"))
54+
seed = (RuntimeError, "session-level diagnostic")
55+
cur._connection.messages.append(seed)
4256
cur.fetchall()
43-
assert cur._connection.messages == []
57+
assert cur._connection.messages == [seed]
4458

45-
def test_nextset_clears_connection_messages_before_raising(self) -> None:
59+
def test_nextset_does_not_clear_connection_messages_before_raising(self) -> None:
4660
cur = _make_cursor()
47-
cur._connection.messages.append((RuntimeError, "stale"))
61+
seed = (RuntimeError, "session-level diagnostic")
62+
cur._connection.messages.append(seed)
4863
with pytest.raises(NotSupportedError):
4964
cur.nextset()
50-
assert cur._connection.messages == []
65+
assert cur._connection.messages == [seed]

0 commit comments

Comments
 (0)