Skip to content

Commit de25a9e

Browse files
Document hasattr-vs-stdlib divergence on PEP 249 optional-extension stubs
PEP 249 §7 + the cross-driver ``except dbapi.Error:`` discipline justifies exposing ``tpc_*``, ``callproc``, ``nextset``, ``scroll``, etc. as always-raising stubs (the NotSupportedError surface stays inside the dbapi.Error hierarchy instead of leaking AttributeError). The trade-off: ``hasattr(conn, "tpc_begin")`` returns True against this driver, while stdlib ``sqlite3`` omits the methods entirely so ``hasattr`` returns False there. Code that feature-detects via ``hasattr`` mistakenly takes the "supported" branch on this driver and surfaces ``NotSupportedError`` from inside the call. Document the trap at the top of the stub block in ``connection.py`` and on ``Cursor.callproc`` (the canonical cross-driver feature-detection target). Add a test pinning both halves of the contract — the stub is present (``hasattr=True``, callable) AND raises ``NotSupportedError`` — so a future "harmonise hasattr with stdlib" change has to flip the test deliberately. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 4ccd9cb commit de25a9e

3 files changed

Lines changed: 105 additions & 0 deletions

File tree

src/dqlitedbapi/connection.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1354,6 +1354,18 @@ def closed(self) -> bool:
13541354
# via NotSupportedError so cross-driver code that calls them
13551355
# inside ``except sqlite3.Error:`` catches uniformly. dqlite-
13561356
# server does not implement any of these.
1357+
#
1358+
# **Note for cross-driver code porting from stdlib ``sqlite3``:**
1359+
# ``hasattr(conn, "tpc_begin")`` returns ``True`` on this driver
1360+
# because the stub IS defined (it just unconditionally raises).
1361+
# Stdlib ``sqlite3`` has no ``tpc_*`` methods at all, so
1362+
# ``hasattr`` returns ``False`` there. Code that feature-detects
1363+
# via ``hasattr`` will mistakenly take the "supported" branch
1364+
# against dqlitedbapi and then surface ``NotSupportedError``
1365+
# from inside the call. To portably test for support, use a
1366+
# ``try: conn.tpc_begin(xid); except dbapi.NotSupportedError:``
1367+
# block instead of ``hasattr``. The same caveat applies to
1368+
# ``callproc`` / ``nextset`` / ``scroll`` on the cursor side.
13571369

13581370
def tpc_begin(self, xid: object) -> NoReturn:
13591371
raise NotSupportedError("dqlite does not support two-phase commit")

src/dqlitedbapi/cursor.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1326,6 +1326,14 @@ def callproc(self, procname: str, parameters: Sequence[Any] | None = None) -> No
13261326
dqlite (and SQLite) have no stored-procedure concept. Annotated
13271327
``NoReturn`` because the body unconditionally raises
13281328
``NotSupportedError`` — symmetric with ``nextset`` below.
1329+
1330+
**Note for cross-driver code porting from stdlib ``sqlite3``:**
1331+
``hasattr(cur, "callproc")`` returns ``True`` here because the
1332+
method is defined (it just unconditionally raises). Stdlib
1333+
``sqlite3.Cursor`` has no ``callproc`` / ``nextset`` / ``scroll``
1334+
attributes, so ``hasattr`` returns ``False`` there. Use
1335+
``try / except NotSupportedError`` for portable feature-
1336+
detection; ``hasattr`` is misleading on this driver.
13291337
"""
13301338
# PEP 249 §6.1.1 names ``callproc`` among the cursor methods
13311339
# that clear ``Connection.messages`` / ``Cursor.messages``.
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
"""Pin: PEP 249 optional-extension stubs are exposed as
2+
always-raising methods (PEP 249 §7 + cross-driver
3+
``except dbapi.Error:`` discipline). This means ``hasattr``
4+
returns True against this driver, diverging from stdlib
5+
``sqlite3`` (which omits ``tpc_*`` / ``callproc`` / ``nextset``
6+
/ ``scroll`` entirely).
7+
8+
The divergence is documented in the relevant module / method
9+
docstrings. Cross-driver code porting from stdlib must use
10+
``try/except NotSupportedError`` for feature detection,
11+
not ``hasattr``. This test pins the documented contract so a
12+
future "harmonise hasattr with stdlib" change has to flip the
13+
test deliberately.
14+
"""
15+
16+
from __future__ import annotations
17+
18+
import pytest
19+
20+
from dqlitedbapi.connection import Connection
21+
from dqlitedbapi.exceptions import NotSupportedError
22+
23+
24+
@pytest.fixture
25+
def conn() -> Connection:
26+
return Connection("localhost:9001", timeout=1.0)
27+
28+
29+
@pytest.mark.parametrize(
30+
"name",
31+
[
32+
"tpc_begin",
33+
"tpc_prepare",
34+
"tpc_commit",
35+
"tpc_rollback",
36+
"tpc_recover",
37+
"xid",
38+
"enable_load_extension",
39+
"load_extension",
40+
"backup",
41+
"iterdump",
42+
"create_function",
43+
"create_aggregate",
44+
"create_collation",
45+
"create_window_function",
46+
],
47+
)
48+
def test_connection_stub_methods_present_for_pep249_compliance(conn: Connection, name: str) -> None:
49+
"""``hasattr`` returns True — the stub is present so
50+
``except dbapi.Error:`` catches the rejection uniformly.
51+
Stdlib ``sqlite3`` omits these (so ``hasattr`` is False
52+
there). Documented divergence."""
53+
assert hasattr(conn, name)
54+
method = getattr(conn, name)
55+
assert callable(method)
56+
57+
58+
def test_connection_tpc_methods_raise_not_supported(conn: Connection) -> None:
59+
"""Stubs raise ``NotSupportedError`` (a ``dbapi.Error``
60+
subclass) so cross-driver ``except dbapi.Error:`` catches.
61+
The ``hasattr`` trap is the cost; the catch-uniformity is
62+
the benefit. Pin both halves."""
63+
with pytest.raises(NotSupportedError, match="two-phase commit"):
64+
conn.tpc_begin(object())
65+
66+
67+
def test_cursor_callproc_nextset_scroll_present_but_raise() -> None:
68+
"""Cursor stubs match the connection-side discipline:
69+
present + raise NotSupportedError. ``hasattr`` is True;
70+
the ``try/except`` portable path produces the right answer."""
71+
conn = Connection("localhost:9001", timeout=1.0)
72+
cur = conn.cursor()
73+
try:
74+
for name in ("callproc", "nextset", "scroll"):
75+
assert hasattr(cur, name)
76+
77+
with pytest.raises(NotSupportedError, match="stored procedures"):
78+
cur.callproc("foo")
79+
with pytest.raises(NotSupportedError, match="multiple result sets"):
80+
cur.nextset()
81+
with pytest.raises(NotSupportedError, match="not scrollable"):
82+
cur.scroll(0)
83+
finally:
84+
cur.close()
85+
conn._closed = True

0 commit comments

Comments
 (0)