Skip to content

Commit dc034bc

Browse files
test(dbapi): pin aconnect cleanup and CTE-prefix stripper edges
pytest --cov reported two high-value clusters of uncovered lines: aio/__init__.py:198-209 — aconnect()'s partial-construct cleanup path. If AsyncConnection.connect() raises after the constructor returned, aconnect()'s except-clause closes the partially- constructed connection (with contextlib.suppress(Exception) so the close error doesn't mask the original) and re-raises. Three tests pin: (1) close runs on Exception failure; (2) original exception wins when both connect AND close raise (suppress contract); (3) BaseException catch covers CancelledError and still runs the close. cursor.py:481-521 — _strip_leading_with_clause's edges: - L481-483 — RECURSIVE keyword skip after WITH - L494 — AS( (no space) precedence over AS<space> - L496/500 — malformed-CTE fallbacks (no AS / AS without paren) - L510 — unbalanced-paren fallback - L518-521 — comma-separated multi-CTE iteration Six tests on the helper directly + four higher-level tests via _is_dml_with_returning (the production caller used by executemany's row-returning gate). After this commit, dbapi coverage rises from 96% to 97%; cursor.py specifically reaches 100%, aio/__init__.py reaches 100%. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 395171b commit dc034bc

2 files changed

Lines changed: 193 additions & 0 deletions

File tree

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
"""Pin ``aconnect()``'s partial-construct cleanup contract.
2+
3+
If ``AsyncConnection.connect()`` raises after the constructor
4+
returned, ``aconnect()``'s except-clause closes the partially-
5+
constructed connection (with ``contextlib.suppress(Exception)`` so
6+
the close error doesn't mask the original) and re-raises. Without
7+
coverage, a refactor that loses the suppress wrapper or the close
8+
call would silently leak loop-bound locks, transports, and reader
9+
tasks on a connect failure.
10+
11+
Drives ``aio/__init__.py:198-209`` reported as uncovered by
12+
``pytest --cov``.
13+
"""
14+
15+
from __future__ import annotations
16+
17+
import asyncio
18+
19+
import pytest
20+
21+
import dqlitedbapi.exceptions
22+
from dqlitedbapi.aio import aconnect
23+
from dqlitedbapi.aio.connection import AsyncConnection
24+
25+
26+
@pytest.mark.asyncio
27+
async def test_aconnect_calls_close_on_connect_failure(
28+
monkeypatch: pytest.MonkeyPatch,
29+
) -> None:
30+
"""If AsyncConnection.connect() raises an Exception, aconnect()
31+
must call close() on the partially-constructed instance and
32+
re-raise the original exception."""
33+
closed: list[bool] = []
34+
35+
async def _failing_connect(self: AsyncConnection) -> None:
36+
raise dqlitedbapi.exceptions.OperationalError("boom", code=1)
37+
38+
async def _spy_close(self: AsyncConnection) -> None:
39+
closed.append(True)
40+
41+
monkeypatch.setattr(AsyncConnection, "connect", _failing_connect)
42+
monkeypatch.setattr(AsyncConnection, "close", _spy_close)
43+
44+
with pytest.raises(dqlitedbapi.exceptions.OperationalError, match="boom"):
45+
await aconnect("localhost:9001")
46+
47+
assert closed == [True], "aconnect must close the partial conn on connect failure"
48+
49+
50+
@pytest.mark.asyncio
51+
async def test_aconnect_swallows_close_error_to_preserve_original(
52+
monkeypatch: pytest.MonkeyPatch,
53+
) -> None:
54+
"""If close() ALSO fails during cleanup, the original connect()
55+
exception must still propagate (close failure is secondary).
56+
Pin the contextlib.suppress(Exception) wrapper around the
57+
cleanup close."""
58+
59+
async def _failing_connect(self: AsyncConnection) -> None:
60+
raise dqlitedbapi.exceptions.OperationalError("primary", code=1)
61+
62+
async def _failing_close(self: AsyncConnection) -> None:
63+
raise RuntimeError("secondary close failure")
64+
65+
monkeypatch.setattr(AsyncConnection, "connect", _failing_connect)
66+
monkeypatch.setattr(AsyncConnection, "close", _failing_close)
67+
68+
with pytest.raises(dqlitedbapi.exceptions.OperationalError, match="primary"):
69+
await aconnect("localhost:9001")
70+
71+
72+
@pytest.mark.asyncio
73+
async def test_aconnect_propagates_cancellederror(
74+
monkeypatch: pytest.MonkeyPatch,
75+
) -> None:
76+
"""CancelledError during connect() must propagate (BaseException
77+
catch covers it, not just Exception) AND the close cleanup must
78+
still run. Pin the BaseException-vs-Exception choice — narrowing
79+
to Exception would skip cleanup on async cancellation."""
80+
closed: list[bool] = []
81+
82+
async def _cancelled_connect(self: AsyncConnection) -> None:
83+
raise asyncio.CancelledError()
84+
85+
async def _spy_close(self: AsyncConnection) -> None:
86+
closed.append(True)
87+
88+
monkeypatch.setattr(AsyncConnection, "connect", _cancelled_connect)
89+
monkeypatch.setattr(AsyncConnection, "close", _spy_close)
90+
91+
with pytest.raises(asyncio.CancelledError):
92+
await aconnect("localhost:9001")
93+
94+
assert closed == [True]
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
"""Pin ``_strip_leading_with_clause`` and ``_is_dml_with_returning``
2+
edge branches uncovered by ``pytest --cov``:
3+
4+
- ``cursor.py:481-483`` — RECURSIVE keyword skip after WITH.
5+
- ``cursor.py:494`` — ``AS(`` (no space before paren) precedence.
6+
- ``cursor.py:496`` — malformed-CTE fallback (no AS found).
7+
- ``cursor.py:500`` — malformed-CTE fallback (AS without
8+
following paren).
9+
- ``cursor.py:510`` — unbalanced-paren fallback.
10+
- ``cursor.py:518-521`` — comma-separated multi-CTE iteration.
11+
12+
The CTE parser admits ``WITH ... DELETE/INSERT/UPDATE`` shapes
13+
through ``executemany`` (per a prior commit). The uncovered edges
14+
silently apply the wrong fallback; without tests, a refactor that
15+
"tightens" the parser could break valid CTE shapes (RECURSIVE,
16+
multi-CTE) silently.
17+
18+
The stripper takes ALREADY-NORMALIZED SQL (uppercase, single
19+
spaces, leading whitespace stripped — see callers). Tests pass
20+
the post-normalization shape directly.
21+
"""
22+
23+
from __future__ import annotations
24+
25+
from dqlitedbapi.cursor import _is_dml_with_returning, _strip_leading_with_clause
26+
27+
28+
class TestStripLeadingWithClauseEdges:
29+
def test_recursive_keyword_is_skipped_after_with(self) -> None:
30+
"""``WITH RECURSIVE c(n) AS (...) DELETE ...`` — pin the
31+
RECURSIVE skip at cursor.py:481-483."""
32+
normalized = (
33+
"WITH RECURSIVE C(N) AS (SELECT 1 UNION SELECT N+1 FROM C) "
34+
"DELETE FROM T WHERE ID IN (SELECT N FROM C)"
35+
)
36+
body = _strip_leading_with_clause(normalized)
37+
assert body.startswith("DELETE FROM T")
38+
39+
def test_as_paren_no_space_is_handled(self) -> None:
40+
"""``WITH C AS(SELECT 1) DELETE ...`` — pin the ``AS(``
41+
precedence path at cursor.py:494."""
42+
normalized = "WITH C AS(SELECT 1) DELETE FROM T"
43+
body = _strip_leading_with_clause(normalized)
44+
assert body.startswith("DELETE FROM T")
45+
46+
def test_malformed_no_as_falls_back_to_input(self) -> None:
47+
"""``WITH C (SELECT 1) FROM T`` — no ``AS`` keyword. Stripper
48+
returns the input unchanged. Pin the fallback at
49+
cursor.py:496."""
50+
normalized = "WITH C (SELECT 1) FROM T"
51+
assert _strip_leading_with_clause(normalized) == normalized
52+
53+
def test_malformed_as_without_following_paren_falls_back(self) -> None:
54+
"""``WITH C AS SELECT 1 FROM T`` — AS without following
55+
``(``. Stripper returns the input unchanged. Pin the
56+
fallback at cursor.py:500."""
57+
normalized = "WITH C AS SELECT 1 FROM T"
58+
assert _strip_leading_with_clause(normalized) == normalized
59+
60+
def test_unbalanced_parens_fall_back_to_input(self) -> None:
61+
"""An unclosed CTE body — depth never returns to 0. Stripper
62+
returns the input unchanged. Pin cursor.py:510."""
63+
normalized = "WITH C AS (SELECT 1, (2) DELETE FROM T"
64+
assert _strip_leading_with_clause(normalized) == normalized
65+
66+
def test_comma_separated_multi_cte_strips_all(self) -> None:
67+
"""``WITH A AS (...), B AS (...) DELETE ...`` — multiple
68+
comma-separated CTEs. Stripper iterates the loop body.
69+
Pin cursor.py:518-521."""
70+
normalized = "WITH A AS (SELECT 1), B AS (SELECT 2) DELETE FROM T"
71+
body = _strip_leading_with_clause(normalized)
72+
assert body.startswith("DELETE FROM T")
73+
74+
75+
class TestIsDmlWithReturningCteShapes:
76+
"""Higher-level pins via the public callers. Each shape that
77+
succeeds at the stripper above must be admitted as DML by
78+
``_is_dml_with_returning`` (the gate ``executemany`` uses)."""
79+
80+
def test_with_recursive_dml_is_admitted(self) -> None:
81+
sql = (
82+
"WITH RECURSIVE c(n) AS (SELECT 1 UNION SELECT n+1 FROM c) "
83+
"DELETE FROM t WHERE id IN (SELECT n FROM c)"
84+
)
85+
assert _is_dml_with_returning(sql) is True
86+
87+
def test_multi_cte_dml_is_admitted(self) -> None:
88+
sql = "WITH a AS (SELECT 1), b AS (SELECT 2) DELETE FROM t"
89+
assert _is_dml_with_returning(sql) is True
90+
91+
def test_with_as_paren_no_space_dml_is_admitted(self) -> None:
92+
sql = "WITH c AS(SELECT 1) DELETE FROM t"
93+
assert _is_dml_with_returning(sql) is True
94+
95+
def test_malformed_with_is_not_admitted_as_dml(self) -> None:
96+
"""Stripper returns input unchanged on malformed CTE; the
97+
downstream check sees ``WITH ...`` as the leading token,
98+
which is not DML."""
99+
assert _is_dml_with_returning("WITH c (SELECT 1) FROM t") is False

0 commit comments

Comments
 (0)