Skip to content

Commit 79d434e

Browse files
Bump PEP 249 threadsafety from 1 to 2
Three-agent review (architect / code reviewer / skeptic) on the deferred .md flagged a factual error in the .md's "stay at 1" case: it claimed stdlib advertises threadsafety=1 as precedent. Stdlib actually advertises threadsafety=3 since CPython 3.11 (bpo-45613 / gh-31464 made it dynamic via SQLITE_THREADSAFE mode; serialized = tier 3). The repo's own README:209 already acknowledged this contradiction. With the stdlib-precedent leg of the .md's case gone, the remaining "stay at 1" arguments collapsed: - Reason 1 (default behavior matches =1): rebutted by stdlib, which advertises tier 3 while defaulting check_same_thread=True and enforcing strict per-thread at the Python layer. PEP 249 permits "advertise ceiling, default to floor." - Reason 2 (cursors are not thread-safe): still true — but that argues for tier 2 (which doesn't claim cursor sharing), not tier 1. Tier 3 would be the lie. - Reason 3 (cross-driver code routes per threadsafety): grep of installed SA, Django, sqlalchemy-dqlite, dqlite-client, and dqlite-wire finds zero hits beyond pin tests. The hypothetical capability-detection consumer doesn't exist. After Phase 1 + Phase 2 hardening (check_same_thread=False honored, _op_lock serializes the wire, _state_lock locks _transaction_owner read-check-reserve + _cursors WeakSet add/discard), the driver's actual capability ceiling is exactly PEP 249 tier 2: "Threads may share the module and connections." Cursors remain per-thread per the documented contract. Flip the literal in __init__.py:117 and aio/__init__.py:128; update the comment blocks to explain the ceiling+floor pattern; update three pin tests (test_module_attributes, test_aio_module_attributes, sqlalchemy-dqlite/test_dialect) from ==1 to ==2 with rationale comments; update README "PEP 249 Compliance" and "Differences from stdlib sqlite3" sections to reflect the bump and point at the tier-3 future-work issue. Tier 3 (cursor sharing — stdlib parity) is tracked separately in issues/dbapi-threadsafety-tier-3-cursor-sharing-stdlib-parity.md (next commit). No fundamental architectural blocker — _op_lock plus GIL atomicity provides the same two layers stdlib uses; cost is convention + optional per-cursor locking if we want strict-Tier-3 instead of stdlib's loose-Tier-3. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent f61c75f commit 79d434e

6 files changed

Lines changed: 94 additions & 28 deletions

File tree

‎README.md‎

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,9 @@ asyncio.run(main())
3939
## PEP 249 Compliance
4040

4141
- `apilevel = "2.0"`
42-
- `threadsafety = 1`
42+
- `threadsafety = 2` (capability ceiling; default `check_same_thread=True`
43+
enforces strict per-thread — matches stdlib sqlite3's "advertise
44+
ceiling, default to floor" pattern)
4345
- `paramstyle = "qmark"`
4446

4547
## Transactions
@@ -206,10 +208,16 @@ borrowed from one.
206208
stdlib silently accepts empty / whitespace-only / comment-only SQL
207209
as a no-op; the driver pre-flight rejects per PEP 249 §7 to surface
208210
caller bugs at the call site.
209-
- **`threadsafety = 1`** (stdlib reports `3`). Each Connection is
210-
thread-affine: methods called from a foreign thread raise
211-
`ProgrammingError`. Use one Connection per thread or use the async
212-
surface for a single-thread-per-loop model.
211+
- **`threadsafety = 2`** (stdlib reports `3` — we don't claim Tier 3
212+
because cursors aren't shareable per the documented "share
213+
connections, not cursors" contract). Tier 2 = "threads may share
214+
the module AND connections"; matches the dbapi's
215+
``check_same_thread=False`` opt-in behaviour. Default
216+
``check_same_thread=True`` enforces strict per-thread; methods
217+
called from a foreign thread on a default-mode Connection raise
218+
``ProgrammingError``. Tier 3 (cursor sharing) is tracked as
219+
future work in
220+
``issues/dbapi-threadsafety-tier-3-cursor-sharing-stdlib-parity.md``.
213221
- **No `executescript` / `create_function` / `create_aggregate` /
214222
`create_window_function` / `iterdump` / `backup` / `set_authorizer`
215223
/ `serialize` / `blobopen`.** stdlib-specific APIs that have no

‎src/dqlitedbapi/__init__.py‎

Lines changed: 31 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -107,14 +107,38 @@ class aliases as in stdlib ``sqlite3.dbapi2``), so they raise PEP
107107

108108
# PEP 249 module-level attributes
109109
apilevel: _Final[_Literal["2.0"]] = "2.0"
110-
# PEP 249 value 1: threads may share the module.
110+
# PEP 249 value 2: threads may share the module and connections.
111111
#
112-
# This driver is stricter than the PEP minimum: each Connection is
113-
# bound to the thread that created it. Any method call from a
114-
# different thread raises ProgrammingError. Use one Connection per
115-
# thread, or use the async API (dqlitedbapi.aio.aconnect) for a
116-
# single-thread-per-loop model.
117-
threadsafety: _Final[_Literal[1]] = 1
112+
# This declares the driver's CAPABILITY ceiling: connections are
113+
# safe to share across threads once the user opts in via
114+
# ``check_same_thread=False`` at ``connect()`` (matches stdlib
115+
# sqlite3's convention — stdlib advertises ``threadsafety = 3``
116+
# while defaulting ``check_same_thread=True`` to enforce strict
117+
# per-thread at the Python layer). Cursors are explicitly NOT
118+
# shareable across threads — the documented contract is "share
119+
# connections, not cursors" (see ``Connection`` docstring); the
120+
# ceiling stays at PEP 249 tier 2 for that reason. Tier 3 (cursors
121+
# shareable too) is tracked as future work in
122+
# ``issues/dbapi-threadsafety-tier-3-cursor-sharing-stdlib-parity.md``.
123+
#
124+
# DEFAULT enforcement is still per-thread: ``check_same_thread``
125+
# defaults to ``True`` and ``Connection._check_thread()`` raises
126+
# ``ProgrammingError`` on cross-thread method calls. Pass
127+
# ``check_same_thread=False`` to use the connection sharing
128+
# advertised here. The wire is always serialised by ``_op_lock``;
129+
# the Phase 2 hardening (``_state_lock`` for transaction owner
130+
# read-check-reserve, ``_cursors`` WeakSet add/discard) closes
131+
# the cross-thread correctness gaps for the connection-shared
132+
# path.
133+
#
134+
# This declaration uses the "advertise ceiling, default to floor"
135+
# pattern stdlib sqlite3 established since CPython 3.11
136+
# (bpo-45613 / gh-31464 made ``sqlite3.threadsafety`` dynamic via
137+
# ``SQLITE_THREADSAFE`` mode; serialized = tier 3). The pattern
138+
# describes capability rather than default enforcement;
139+
# capability-detection libraries reading this value learn the
140+
# safe-with-opt-in ceiling.
141+
threadsafety: _Final[_Literal[2]] = 2
118142
paramstyle: _Final[_Literal["qmark"]] = "qmark" # Question mark style: WHERE name=?
119143

120144
# SQLite compatibility attributes (for SQLAlchemy).

‎src/dqlitedbapi/aio/__init__.py‎

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -120,12 +120,20 @@
120120
logger = logging.getLogger(__name__)
121121

122122
apilevel: _Final[_Literal["2.0"]] = "2.0"
123-
# PEP 249 value 1: threads may share the module.
123+
# PEP 249 value 2: threads may share the module and connections.
124124
#
125-
# The async API is further restricted: each AsyncConnection is bound
126-
# to the event loop it was first used on (see dqlitedbapi.aio.connection).
127-
# Use one AsyncConnection per loop.
128-
threadsafety: _Final[_Literal[1]] = 1
125+
# Mirrors the sync surface declaration (see ``dqlitedbapi.__init__``
126+
# for the full rationale on the "advertise ceiling, default to
127+
# floor" convention). The sync ``Connection`` opts into cross-
128+
# thread sharing via ``check_same_thread=False``; the async
129+
# ``AsyncConnection`` is bound to its event loop by asyncio's
130+
# structured-concurrency contract (cross-loop use raises via
131+
# ``_check_loop_binding``), so for the async surface the tier-2
132+
# advertisement is informational — the actual capability is
133+
# "share connection across tasks within the same event loop."
134+
# Cursors are NOT shareable across threads/tasks (see
135+
# ``AsyncConnection`` docstring).
136+
threadsafety: _Final[_Literal[2]] = 2
129137
paramstyle: _Final[_Literal["qmark"]] = "qmark" # Question mark style: WHERE name=?
130138

131139
# SQLite compatibility attributes (for SQLAlchemy).

‎tests/test_aio_module_attributes.py‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,10 @@ def test_apilevel(self) -> None:
1111
assert aio.apilevel == "2.0"
1212

1313
def test_threadsafety(self) -> None:
14-
assert aio.threadsafety == 1
14+
# 2 = threads may share the module AND connections — mirrors
15+
# the sync surface bump (see test_module_attributes.py for
16+
# the full rationale).
17+
assert aio.threadsafety == 2
1518

1619
def test_paramstyle(self) -> None:
1720
assert aio.paramstyle == "qmark"

‎tests/test_module_attributes.py‎

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,20 @@ def test_apilevel(self) -> None:
88
assert dqlitedbapi.apilevel == "2.0"
99

1010
def test_threadsafety(self) -> None:
11-
# 1 = threads may share the module, but not connections
12-
assert dqlitedbapi.threadsafety == 1
11+
# 2 = threads may share the module AND connections (cursors
12+
# remain per-thread per the documented contract). The
13+
# declaration is the CAPABILITY ceiling: connections become
14+
# safe to share once the user opts in via
15+
# ``check_same_thread=False``. Default enforcement remains
16+
# strict per-thread. Matches stdlib sqlite3's "advertise
17+
# ceiling, default to floor" convention (stdlib reports 3
18+
# since CPython 3.11 / bpo-45613 while still defaulting
19+
# ``check_same_thread=True``). See ``__init__.py:108-149``
20+
# for the full rationale and
21+
# ``issues/dbapi-threadsafety-tier-3-cursor-sharing-stdlib-parity.md``
22+
# for the future-work proposal that would lift the ceiling
23+
# to 3.
24+
assert dqlitedbapi.threadsafety == 2
1325

1426
def test_paramstyle(self) -> None:
1527
assert dqlitedbapi.paramstyle == "qmark"

‎tests/test_thread_safety_enforcement.py‎

Lines changed: 19 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,16 @@
1-
"""Tests for thread safety enforcement and close() hardening.
2-
3-
The package declares threadsafety=1, meaning connections must not be
4-
shared between threads. Like sqlite3 stdlib, we enforce this with a
5-
thread identity check that raises ProgrammingError.
1+
"""Tests for thread safety DEFAULT enforcement and close() hardening.
2+
3+
The package declares ``threadsafety=2`` (capability ceiling: threads
4+
may share module + connections), but the DEFAULT ``check_same_thread
5+
=True`` enforces strict per-thread connection use via
6+
``_check_thread()``. These tests pin the strict default; the
7+
relaxation under ``check_same_thread=False`` is tested in
8+
``test_check_same_thread_kwarg.py``.
9+
10+
Like stdlib sqlite3 (which advertises ``threadsafety=3`` while
11+
defaulting ``check_same_thread=True``), the capability ceiling
12+
describes what the driver SUPPORTS once the user opts in; default
13+
enforcement is the Python-layer safety net.
614
"""
715

816
import threading
@@ -121,11 +129,14 @@ def wrong_thread() -> None:
121129

122130
def test_row_factory_setter_from_wrong_thread_raises(self) -> None:
123131
"""Setting Connection.row_factory from a different thread must raise
124-
ProgrammingError. The class docstring claims every public method
125-
enforces threadsafety=1; the row_factory setter mutates state and
132+
ProgrammingError under default ``check_same_thread=True``. The
133+
class docstring claims every public method enforces the per-
134+
thread default; the row_factory setter mutates state and
126135
must follow the same discipline. Cross-thread mutation could
127136
otherwise let a foreign thread override the row_factory used by
128-
cursors created on the creator thread."""
137+
cursors created on the creator thread. (Under
138+
``check_same_thread=False`` this restriction relaxes — the
139+
module's ``threadsafety=2`` ceiling allows the sharing.)"""
129140
conn = Connection("localhost:9001")
130141
error: Exception | None = None
131142

0 commit comments

Comments
 (0)