Skip to content

Commit 599cc3d

Browse files
Stub PEP 249 TPC and stdlib sqlite3 helpers with NotSupportedError
PEP 249 §7 says drivers without two-phase-commit MUST raise NotSupportedError on tpc_begin / tpc_prepare / tpc_commit / tpc_rollback / tpc_recover / xid. Today these methods are missing entirely and AttributeError leaks past the dbapi.Error hierarchy, breaking any caller that funnels failures through except Error:. Stdlib sqlite3 raises NotSupportedError on enable_load_extension / load_extension / backup / iterdump / create_function / create_aggregate / create_collation / create_window_function when the underlying SQLite was built without the corresponding feature. dqlite-server doesn't implement any of them; mirror the stdlib contract so cross-driver code branching on sqlite3.NotSupportedError catches them uniformly. Add stubs to both Connection and AsyncConnection. Backup is async on the AsyncConnection side to mirror stdlib's progress-callback pattern; the rest are synchronous because either they don't take a callback or the parameters are loaded eagerly. Also align Connection.close() / AsyncConnection.close() to clear self.messages, completing the PEP 249 §6.1.1 "messages cleared on every standard Connection method" contract that commit/rollback/cursor already honoured. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent e300e8a commit 599cc3d

3 files changed

Lines changed: 311 additions & 0 deletions

File tree

src/dqlitedbapi/aio/connection.py

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -280,6 +280,12 @@ async def close(self) -> None:
280280
"""
281281
if self._closed:
282282
return
283+
# PEP 249 §6.1.1: Connection.messages should be cleared on
284+
# any standard Connection method invocation. Sync and async
285+
# commit/rollback/cursor paths already clear; align close()
286+
# so the contract is uniform across the four required
287+
# methods.
288+
del self.messages[:]
283289
# Fork-after-init: the inherited socket FD is shared with the
284290
# parent and the asyncio op_lock is bound to the parent's
285291
# loop. Driving the async teardown here would either send FIN
@@ -681,6 +687,58 @@ def closed(self) -> bool:
681687
"""
682688
return self._closed
683689

690+
# PEP 249 §7 (TPC) and stdlib sqlite3 parity stubs. Without these
691+
# a caller hits AttributeError which escapes the dbapi.Error
692+
# hierarchy. Same shape as the sync sibling.
693+
694+
async def tpc_begin(self, xid: object) -> NoReturn:
695+
raise NotSupportedError("dqlite does not support two-phase commit")
696+
697+
async def tpc_prepare(self) -> NoReturn:
698+
raise NotSupportedError("dqlite does not support two-phase commit")
699+
700+
async def tpc_commit(self, xid: object | None = None) -> NoReturn:
701+
raise NotSupportedError("dqlite does not support two-phase commit")
702+
703+
async def tpc_rollback(self, xid: object | None = None) -> NoReturn:
704+
raise NotSupportedError("dqlite does not support two-phase commit")
705+
706+
async def tpc_recover(self) -> NoReturn:
707+
raise NotSupportedError("dqlite does not support two-phase commit")
708+
709+
def xid(self, format_id: int, global_transaction_id: str, branch_qualifier: str) -> NoReturn:
710+
raise NotSupportedError("dqlite does not support two-phase commit")
711+
712+
def enable_load_extension(self, enabled: bool) -> NoReturn:
713+
raise NotSupportedError("dqlite-server does not support runtime extension loading")
714+
715+
def load_extension(self, path: str, *, entrypoint: str | None = None) -> NoReturn:
716+
raise NotSupportedError("dqlite-server does not support runtime extension loading")
717+
718+
async def backup(self, *args: object, **kwargs: object) -> NoReturn:
719+
raise NotSupportedError(
720+
"dqlite does not support the stdlib sqlite3 online backup API; "
721+
"use the dqlite-server dump/restore mechanism instead"
722+
)
723+
724+
def iterdump(self) -> NoReturn:
725+
raise NotSupportedError(
726+
"dqlite does not support stdlib sqlite3 iterdump; "
727+
"use the dqlite-server dump/restore mechanism instead"
728+
)
729+
730+
def create_function(self, *args: object, **kwargs: object) -> NoReturn:
731+
raise NotSupportedError("dqlite-server does not support user-defined SQL functions")
732+
733+
def create_aggregate(self, *args: object, **kwargs: object) -> NoReturn:
734+
raise NotSupportedError("dqlite-server does not support user-defined SQL aggregates")
735+
736+
def create_collation(self, *args: object, **kwargs: object) -> NoReturn:
737+
raise NotSupportedError("dqlite-server does not support user-defined SQL collations")
738+
739+
def create_window_function(self, *args: object, **kwargs: object) -> NoReturn:
740+
raise NotSupportedError("dqlite-server does not support user-defined SQL window functions")
741+
684742
def __repr__(self) -> str:
685743
state = "closed" if self._closed else ("connected" if self._async_conn else "unused")
686744
return f"<AsyncConnection address={self._address!r} database={self._database!r} {state}>"

src/dqlitedbapi/connection.py

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -802,6 +802,12 @@ def close(self) -> None:
802802
# short-circuit. Mirrors the cursor-side resolution.
803803
if self._closed:
804804
return
805+
# PEP 249 §6.1.1: Connection.messages should be cleared on
806+
# any standard Connection method invocation. The sibling
807+
# commit/rollback/cursor paths already clear; align close()
808+
# so the contract is uniform across the four required
809+
# methods.
810+
del self.messages[:]
805811
# Fork-after-init: the inherited connection FDs are shared
806812
# with the parent, the inherited daemon loop thread did not
807813
# survive fork (only the calling thread crosses), and
@@ -1116,6 +1122,64 @@ def closed(self) -> bool:
11161122
"""
11171123
return self._closed
11181124

1125+
# PEP 249 §7 (TPC extension) and stdlib sqlite3 parity stubs.
1126+
# PEP 249 says drivers without TPC support MUST raise
1127+
# NotSupportedError on the TPC methods rather than letting
1128+
# AttributeError leak (which escapes the dbapi.Error hierarchy).
1129+
# The stdlib-sqlite3 helpers (load_extension, backup, iterdump,
1130+
# create_function/aggregate/collation) similarly should surface
1131+
# via NotSupportedError so cross-driver code that calls them
1132+
# inside ``except sqlite3.Error:`` catches uniformly. dqlite-
1133+
# server does not implement any of these.
1134+
1135+
def tpc_begin(self, xid: object) -> NoReturn:
1136+
raise NotSupportedError("dqlite does not support two-phase commit")
1137+
1138+
def tpc_prepare(self) -> NoReturn:
1139+
raise NotSupportedError("dqlite does not support two-phase commit")
1140+
1141+
def tpc_commit(self, xid: object | None = None) -> NoReturn:
1142+
raise NotSupportedError("dqlite does not support two-phase commit")
1143+
1144+
def tpc_rollback(self, xid: object | None = None) -> NoReturn:
1145+
raise NotSupportedError("dqlite does not support two-phase commit")
1146+
1147+
def tpc_recover(self) -> NoReturn:
1148+
raise NotSupportedError("dqlite does not support two-phase commit")
1149+
1150+
def xid(self, format_id: int, global_transaction_id: str, branch_qualifier: str) -> NoReturn:
1151+
raise NotSupportedError("dqlite does not support two-phase commit")
1152+
1153+
def enable_load_extension(self, enabled: bool) -> NoReturn:
1154+
raise NotSupportedError("dqlite-server does not support runtime extension loading")
1155+
1156+
def load_extension(self, path: str, *, entrypoint: str | None = None) -> NoReturn:
1157+
raise NotSupportedError("dqlite-server does not support runtime extension loading")
1158+
1159+
def backup(self, *args: object, **kwargs: object) -> NoReturn:
1160+
raise NotSupportedError(
1161+
"dqlite does not support the stdlib sqlite3 online backup API; "
1162+
"use the dqlite-server dump/restore mechanism instead"
1163+
)
1164+
1165+
def iterdump(self) -> NoReturn:
1166+
raise NotSupportedError(
1167+
"dqlite does not support stdlib sqlite3 iterdump; "
1168+
"use the dqlite-server dump/restore mechanism instead"
1169+
)
1170+
1171+
def create_function(self, *args: object, **kwargs: object) -> NoReturn:
1172+
raise NotSupportedError("dqlite-server does not support user-defined SQL functions")
1173+
1174+
def create_aggregate(self, *args: object, **kwargs: object) -> NoReturn:
1175+
raise NotSupportedError("dqlite-server does not support user-defined SQL aggregates")
1176+
1177+
def create_collation(self, *args: object, **kwargs: object) -> NoReturn:
1178+
raise NotSupportedError("dqlite-server does not support user-defined SQL collations")
1179+
1180+
def create_window_function(self, *args: object, **kwargs: object) -> NoReturn:
1181+
raise NotSupportedError("dqlite-server does not support user-defined SQL window functions")
1182+
11191183
def __repr__(self) -> str:
11201184
state = "closed" if self._closed else ("connected" if self._async_conn else "unused")
11211185
return f"<Connection address={self._address!r} database={self._database!r} {state}>"
Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
"""Pin: optional PEP 249 §7 (TPC) and stdlib-sqlite3-parity helpers
2+
raise ``NotSupportedError`` on both sync and async ``Connection``,
3+
not ``AttributeError``.
4+
5+
PEP 249 §7 says drivers without two-phase-commit support MUST raise
6+
``NotSupportedError`` from ``tpc_*`` methods. AttributeError escapes
7+
the dbapi.Error hierarchy, so a caller's ``except Error:`` skips it
8+
— users porting from psycopg / asyncpg / stdlib sqlite3 expect the
9+
PEP 249 surface to be uniform.
10+
11+
Stdlib sqlite3-parity helpers (``load_extension``, ``backup``,
12+
``iterdump``, ``create_function`` / ``_aggregate`` / ``_collation`` /
13+
``_window_function``) are not part of PEP 249, but stdlib raises
14+
``sqlite3.NotSupportedError`` (a PEP 249 ``NotSupportedError``) when
15+
the underlying SQLite was built without the corresponding feature.
16+
Mirror that contract so cross-driver code branching on
17+
``sqlite3.NotSupportedError`` continues to work.
18+
19+
dqlite-server does not implement any of these; the stubs are
20+
permanent rejections, not "not yet."
21+
"""
22+
23+
from __future__ import annotations
24+
25+
import pytest
26+
27+
import dqlitedbapi
28+
from dqlitedbapi.aio import AsyncConnection
29+
from dqlitedbapi.exceptions import NotSupportedError
30+
31+
32+
@pytest.fixture
33+
def conn() -> dqlitedbapi.Connection:
34+
c = dqlitedbapi.connect("127.0.0.1:9999")
35+
yield c
36+
c._closed_flag[0] = True
37+
38+
39+
@pytest.fixture
40+
def aconn() -> AsyncConnection:
41+
return AsyncConnection("127.0.0.1:9999")
42+
43+
44+
class TestSyncTpcStubs:
45+
def test_tpc_begin(self, conn: dqlitedbapi.Connection) -> None:
46+
with pytest.raises(NotSupportedError):
47+
conn.tpc_begin(object())
48+
49+
def test_tpc_prepare(self, conn: dqlitedbapi.Connection) -> None:
50+
with pytest.raises(NotSupportedError):
51+
conn.tpc_prepare()
52+
53+
def test_tpc_commit(self, conn: dqlitedbapi.Connection) -> None:
54+
with pytest.raises(NotSupportedError):
55+
conn.tpc_commit()
56+
57+
def test_tpc_rollback(self, conn: dqlitedbapi.Connection) -> None:
58+
with pytest.raises(NotSupportedError):
59+
conn.tpc_rollback()
60+
61+
def test_tpc_recover(self, conn: dqlitedbapi.Connection) -> None:
62+
with pytest.raises(NotSupportedError):
63+
conn.tpc_recover()
64+
65+
def test_xid(self, conn: dqlitedbapi.Connection) -> None:
66+
with pytest.raises(NotSupportedError):
67+
conn.xid(1, "g", "b")
68+
69+
70+
class TestSyncStdlibParityStubs:
71+
def test_enable_load_extension(self, conn: dqlitedbapi.Connection) -> None:
72+
with pytest.raises(NotSupportedError, match="extension"):
73+
conn.enable_load_extension(True)
74+
75+
def test_load_extension(self, conn: dqlitedbapi.Connection) -> None:
76+
with pytest.raises(NotSupportedError, match="extension"):
77+
conn.load_extension("foo.so")
78+
79+
def test_backup(self, conn: dqlitedbapi.Connection) -> None:
80+
with pytest.raises(NotSupportedError, match="backup"):
81+
conn.backup()
82+
83+
def test_iterdump(self, conn: dqlitedbapi.Connection) -> None:
84+
with pytest.raises(NotSupportedError, match="iterdump"):
85+
conn.iterdump()
86+
87+
def test_create_function(self, conn: dqlitedbapi.Connection) -> None:
88+
with pytest.raises(NotSupportedError, match="function"):
89+
conn.create_function("name", 0, lambda: 1)
90+
91+
def test_create_aggregate(self, conn: dqlitedbapi.Connection) -> None:
92+
with pytest.raises(NotSupportedError, match="aggregate"):
93+
conn.create_aggregate("name", 0, object)
94+
95+
def test_create_collation(self, conn: dqlitedbapi.Connection) -> None:
96+
with pytest.raises(NotSupportedError, match="collation"):
97+
conn.create_collation("name", lambda a, b: 0)
98+
99+
def test_create_window_function(self, conn: dqlitedbapi.Connection) -> None:
100+
with pytest.raises(NotSupportedError, match="window"):
101+
conn.create_window_function("name", 0, object)
102+
103+
104+
@pytest.mark.asyncio
105+
class TestAsyncTpcStubs:
106+
async def test_tpc_begin(self, aconn: AsyncConnection) -> None:
107+
with pytest.raises(NotSupportedError):
108+
await aconn.tpc_begin(object())
109+
110+
async def test_tpc_prepare(self, aconn: AsyncConnection) -> None:
111+
with pytest.raises(NotSupportedError):
112+
await aconn.tpc_prepare()
113+
114+
async def test_tpc_commit(self, aconn: AsyncConnection) -> None:
115+
with pytest.raises(NotSupportedError):
116+
await aconn.tpc_commit()
117+
118+
async def test_tpc_rollback(self, aconn: AsyncConnection) -> None:
119+
with pytest.raises(NotSupportedError):
120+
await aconn.tpc_rollback()
121+
122+
async def test_tpc_recover(self, aconn: AsyncConnection) -> None:
123+
with pytest.raises(NotSupportedError):
124+
await aconn.tpc_recover()
125+
126+
def test_xid(self, aconn: AsyncConnection) -> None:
127+
with pytest.raises(NotSupportedError):
128+
aconn.xid(1, "g", "b")
129+
130+
131+
class TestAsyncStdlibParityStubs:
132+
def test_enable_load_extension(self, aconn: AsyncConnection) -> None:
133+
with pytest.raises(NotSupportedError, match="extension"):
134+
aconn.enable_load_extension(True)
135+
136+
def test_load_extension(self, aconn: AsyncConnection) -> None:
137+
with pytest.raises(NotSupportedError, match="extension"):
138+
aconn.load_extension("foo.so")
139+
140+
@pytest.mark.asyncio
141+
async def test_backup(self, aconn: AsyncConnection) -> None:
142+
with pytest.raises(NotSupportedError, match="backup"):
143+
await aconn.backup()
144+
145+
def test_iterdump(self, aconn: AsyncConnection) -> None:
146+
with pytest.raises(NotSupportedError, match="iterdump"):
147+
aconn.iterdump()
148+
149+
def test_create_function(self, aconn: AsyncConnection) -> None:
150+
with pytest.raises(NotSupportedError, match="function"):
151+
aconn.create_function("name", 0, lambda: 1)
152+
153+
def test_create_aggregate(self, aconn: AsyncConnection) -> None:
154+
with pytest.raises(NotSupportedError, match="aggregate"):
155+
aconn.create_aggregate("name", 0, object)
156+
157+
def test_create_collation(self, aconn: AsyncConnection) -> None:
158+
with pytest.raises(NotSupportedError, match="collation"):
159+
aconn.create_collation("name", lambda a, b: 0)
160+
161+
def test_create_window_function(self, aconn: AsyncConnection) -> None:
162+
with pytest.raises(NotSupportedError, match="window"):
163+
aconn.create_window_function("name", 0, object)
164+
165+
166+
def test_close_clears_messages() -> None:
167+
"""PEP 249 §6.1.1 requires Connection.messages to be cleared on
168+
every standard Connection method invocation. The four sibling
169+
methods (commit, rollback, cursor) already clear; close() also
170+
clears so the contract is uniform."""
171+
import contextlib as _contextlib
172+
173+
c = dqlitedbapi.connect("127.0.0.1:9999")
174+
c.messages.append((Exception, "stale"))
175+
with _contextlib.suppress(Exception):
176+
c.close()
177+
assert c.messages == []
178+
179+
180+
@pytest.mark.asyncio
181+
async def test_async_close_clears_messages() -> None:
182+
"""Same as the sync sibling, for AsyncConnection."""
183+
import contextlib as _contextlib
184+
185+
c = AsyncConnection("127.0.0.1:9999")
186+
c.messages.append((Exception, "stale"))
187+
with _contextlib.suppress(Exception):
188+
await c.close()
189+
assert c.messages == []

0 commit comments

Comments
 (0)