Skip to content

Commit 04961bd

Browse files
Promote validate_positive_int_or_none to public client surface
The validator was previously exposed only as ``dqliteclient.protocol._validate_positive_int_or_none`` (leading underscore, not in any __all__). dqlitedbapi imported it directly across the package boundary, creating a coupling where a future client refactor that legitimately renamed/moved the helper would silently strand dqlitedbapi at first import — same anti-pattern that ``parse_address`` resolved by promotion. Promote to public ``dqliteclient.validate_positive_int_or_none``, add it to ``__all__``, and keep the leading-underscore name as a backwards-compat alias for one release. Pin the public surface and the alias-still-works contract. The companion dbapi-side import is updated to consume the public name in a separate commit; together the change closes the private- import boundary defect. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 6f6ebaf commit 04961bd

3 files changed

Lines changed: 75 additions & 1 deletion

File tree

src/dqliteclient/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
)
2424
from dqliteclient.node_store import MemoryNodeStore, NodeInfo, NodeStore
2525
from dqliteclient.pool import ConnectionPool
26+
from dqliteclient.protocol import validate_positive_int_or_none
2627
from dqlitewire import (
2728
DEFAULT_MAX_CONTINUATION_FRAMES as _DEFAULT_MAX_CONTINUATION_FRAMES,
2829
)
@@ -53,6 +54,7 @@
5354
"connect",
5455
"create_pool",
5556
"parse_address",
57+
"validate_positive_int_or_none",
5658
]
5759

5860

src/dqliteclient/protocol.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,11 +79,16 @@ def _failure_message(message: str, addr_suffix: str) -> str:
7979
return body + addr_suffix
8080

8181

82-
def _validate_positive_int_or_none(value: int | None, name: str) -> int | None:
82+
def validate_positive_int_or_none(value: int | None, name: str) -> int | None:
8383
"""Shared validation for positive-int-or-None parameters.
8484
8585
Used for both ``max_total_rows`` and ``max_continuation_frames``.
8686
None disables the cap; any int value must be > 0.
87+
88+
Public so downstream packages (``dqlitedbapi``, ``sqlalchemy-dqlite``)
89+
can apply the same construction-time validation without reaching
90+
into private symbols. Same shape as the public ``parse_address`` /
91+
``allowlist_policy`` helpers in this package.
8792
"""
8893
if value is None:
8994
return None
@@ -94,6 +99,12 @@ def _validate_positive_int_or_none(value: int | None, name: str) -> int | None:
9499
return value
95100

96101

102+
# Backwards-compatibility alias for any caller that still imports the
103+
# leading-underscore name. Slated for removal in a future minor — the
104+
# public name is ``validate_positive_int_or_none``.
105+
_validate_positive_int_or_none = validate_positive_int_or_none
106+
107+
97108
class DqliteProtocol:
98109
"""Low-level protocol handler for a single dqlite connection."""
99110

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
"""Pin: ``dqliteclient.validate_positive_int_or_none`` is a public
2+
re-export so downstream packages (dqlitedbapi, sqlalchemy-dqlite) do
3+
not need to reach into private symbols.
4+
5+
The validator was previously available only as
6+
``dqliteclient.protocol._validate_positive_int_or_none`` (leading
7+
underscore, not in any ``__all__``). dqlitedbapi imported it directly,
8+
creating a cross-package coupling that a future client refactor could
9+
silently break. Promote to public, mirror the ``parse_address`` /
10+
``allowlist_policy`` pattern, and pin the public surface so it cannot
11+
disappear without a breaking-change signal.
12+
"""
13+
14+
from __future__ import annotations
15+
16+
import pytest
17+
18+
import dqliteclient
19+
20+
21+
def test_public_validate_positive_int_or_none_callable() -> None:
22+
assert callable(dqliteclient.validate_positive_int_or_none)
23+
24+
25+
def test_public_name_in_all() -> None:
26+
assert "validate_positive_int_or_none" in dqliteclient.__all__
27+
28+
29+
def test_public_validator_accepts_positive_int() -> None:
30+
assert dqliteclient.validate_positive_int_or_none(1, "x") == 1
31+
assert dqliteclient.validate_positive_int_or_none(10**6, "x") == 10**6
32+
33+
34+
def test_public_validator_accepts_none() -> None:
35+
assert dqliteclient.validate_positive_int_or_none(None, "x") is None
36+
37+
38+
def test_public_validator_rejects_zero_and_negative() -> None:
39+
with pytest.raises(ValueError, match="must be > 0"):
40+
dqliteclient.validate_positive_int_or_none(0, "x")
41+
with pytest.raises(ValueError, match="must be > 0"):
42+
dqliteclient.validate_positive_int_or_none(-1, "x")
43+
44+
45+
def test_public_validator_rejects_bool() -> None:
46+
with pytest.raises(TypeError, match="must be int or None"):
47+
dqliteclient.validate_positive_int_or_none(True, "x")
48+
49+
50+
def test_public_validator_rejects_non_int() -> None:
51+
with pytest.raises(TypeError, match="must be int or None"):
52+
dqliteclient.validate_positive_int_or_none(1.5, "x") # type: ignore[arg-type]
53+
54+
55+
def test_legacy_underscore_alias_still_works() -> None:
56+
"""The leading-underscore alias is preserved for one release as a
57+
deprecation cushion. Pin so a future deletion of the alias is an
58+
explicit, reviewed change."""
59+
from dqliteclient.protocol import _validate_positive_int_or_none
60+
61+
assert _validate_positive_int_or_none is dqliteclient.validate_positive_int_or_none

0 commit comments

Comments
 (0)