Skip to content

Commit 2ef31b3

Browse files
committed
Release v2.1.1
- Include LICENSE in source and wheel distributions. - Redact grant tokens in renewal-failure logs. - Validate server endpoints and connect_timeout_s before connecting.
1 parent f6087b0 commit 2ef31b3

11 files changed

Lines changed: 202 additions & 15 deletions

File tree

CHANGELOG.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,14 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## [v2.1.1] - 2026-05-11
9+
10+
### Fixed
11+
12+
- Included `LICENSE` in source and wheel distributions via package metadata.
13+
- Redacted grant tokens in renewal-failure logs while retaining a short diagnostic prefix.
14+
- Validated server endpoint shape, host, port, and `connect_timeout_s` at construction / connection time.
15+
816
## [v2.1.0] - 2026-05-10
917

1018
### Added
@@ -40,6 +48,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
4048

4149
- Removed the v1 pub/sub signal client APIs; dflockd v2 focuses this package on distributed locks and semaphores.
4250

51+
[v2.1.1]: https://github.com/mtingers/dflockd-client-py/releases/tag/v2.1.1
4352
[v2.1.0]: https://github.com/mtingers/dflockd-client-py/releases/tag/v2.1.0
4453
[v2.0.1]: https://github.com/mtingers/dflockd-client-py/releases/tag/v2.0.1
4554
[v2.0.0]: https://github.com/mtingers/dflockd-client-py/releases/tag/v2.0.0

pyproject.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
[project]
22
name = "dflockd-client"
3-
version = "2.1.0"
3+
version = "2.1.1"
44
description = "dflockd python client"
55
readme = "README.md"
66
license = "MIT"
7+
license-files = ["LICENSE"]
78
authors = [{ name = "Matth Ingersoll", email = "matth@mtingers.com" }]
89
requires-python = ">=3.12"
910
dependencies = []

src/dflockd_client/_async.py

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@
2121
from .sharding import (
2222
DEFAULT_SERVERS,
2323
ShardingStrategy,
24+
_validate_server_endpoint,
25+
_validate_servers,
2426
_validate_shard_index,
2527
stable_hash_shard,
2628
)
@@ -113,6 +115,8 @@ async def open_conn(
113115
) -> AsyncConn:
114116
"""Pass ``limit`` so :meth:`StreamReader.readline` can buffer a large
115117
``stats`` JSON line (asyncio's default is 64 KiB, which is too small)."""
118+
proto.validate_connect_timeout_s(connect_timeout_s)
119+
host, port = _validate_server_endpoint(host, port)
116120
reader, writer = await asyncio.wait_for(
117121
asyncio.open_connection(
118122
host,
@@ -317,6 +321,7 @@ def __post_init__(self) -> None:
317321
proto.validate_key("key", self.key)
318322
proto.validate_timeout_s("acquire_timeout_s", self.acquire_timeout_s)
319323
proto.validate_lease_ttl_s(self.lease_ttl_s)
324+
proto.validate_connect_timeout_s(self.connect_timeout_s)
320325
_validate_servers(self.servers)
321326
_validate_renew_ratio(self.renew_ratio)
322327

@@ -595,7 +600,7 @@ def _log_renew_failure(self) -> None:
595600
"%s lost (renew failed): key=%s token=%s",
596601
type(self).__name__,
597602
self.key,
598-
self.token,
603+
proto.token_for_log(self.token),
599604
)
600605

601606
def _update_lease(self, remaining: int) -> None:
@@ -608,11 +613,6 @@ def _update_lease(self, remaining: int) -> None:
608613
# ---------------------------------------------------------------------------
609614

610615

611-
def _validate_servers(servers: list[tuple[str, int]]) -> None:
612-
if not servers:
613-
raise ValueError("servers must be a non-empty list")
614-
615-
616616
def _validate_renew_ratio(ratio: float) -> None:
617617
if not 0 < ratio < 1:
618618
raise ValueError("renew_ratio must be between 0 and 1 (exclusive)")

src/dflockd_client/_protocol.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
from __future__ import annotations
2020

2121
import json
22+
import math
2223
import re
2324
from typing import Any, NoReturn, TypedDict, cast
2425

@@ -141,6 +142,17 @@ def validate_auth_token(token: str) -> None:
141142
raise ValueError(f"auth token too long (max {MAX_AUTH_TOKEN_BYTES} bytes)")
142143

143144

145+
def validate_connect_timeout_s(value: float) -> None:
146+
if isinstance(value, bool) or not isinstance(value, int | float):
147+
raise TypeError("connect_timeout_s must be a number")
148+
if isinstance(value, float) and not math.isfinite(value):
149+
raise ValueError("connect_timeout_s must be finite")
150+
if value <= 0:
151+
raise ValueError("connect_timeout_s must be > 0")
152+
if value > MAX_PROTOCOL_SECONDS:
153+
raise ValueError(f"connect_timeout_s too large (max {MAX_PROTOCOL_SECONDS})")
154+
155+
144156
def validate_prefix(prefix: str) -> None:
145157
if prefix not in ("", "s"):
146158
raise ValueError(f"cmd_prefix must be '' or 's', got {prefix!r}")
@@ -194,6 +206,15 @@ def fence_from_token(token: str) -> int:
194206
return int(token[:16], 16)
195207

196208

209+
def token_for_log(token: str | None) -> str:
210+
"""Return a non-sensitive token label for diagnostic logs."""
211+
if token is None:
212+
return "<none>"
213+
if len(token) <= 8:
214+
return "<redacted>"
215+
return f"{token[:8]}..."
216+
217+
197218
# ---------------------------------------------------------------------------
198219
# Encoders
199220
# ---------------------------------------------------------------------------

src/dflockd_client/_sync.py

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,8 @@
3131
from .sharding import (
3232
DEFAULT_SERVERS,
3333
ShardingStrategy,
34+
_validate_server_endpoint,
35+
_validate_servers,
3436
_validate_shard_index,
3537
stable_hash_shard,
3638
)
@@ -133,6 +135,8 @@ def open_conn(
133135
The returned conn has ``connect_timeout_s`` set as its initial socket
134136
timeout; protocol functions override it per call.
135137
"""
138+
proto.validate_connect_timeout_s(connect_timeout_s)
139+
host, port = _validate_server_endpoint(host, port)
136140
sock = _open_socket(host, port, connect_timeout_s, ssl_context)
137141
return SyncConn(sock)
138142

@@ -365,6 +369,7 @@ def __post_init__(self) -> None:
365369
proto.validate_key("key", self.key)
366370
proto.validate_timeout_s("acquire_timeout_s", self.acquire_timeout_s)
367371
proto.validate_lease_ttl_s(self.lease_ttl_s)
372+
proto.validate_connect_timeout_s(self.connect_timeout_s)
368373
_validate_servers(self.servers)
369374
_validate_renew_ratio(self.renew_ratio)
370375

@@ -649,7 +654,7 @@ def _log_renew_failure(self, stop_event: threading.Event | None = None) -> None:
649654
"%s lost (renew failed): key=%s token=%s",
650655
type(self).__name__,
651656
self.key,
652-
self.token,
657+
proto.token_for_log(self.token),
653658
)
654659

655660
def _update_lease(self, remaining: int) -> None:
@@ -662,11 +667,6 @@ def _update_lease(self, remaining: int) -> None:
662667
# ---------------------------------------------------------------------------
663668

664669

665-
def _validate_servers(servers: list[tuple[str, int]]) -> None:
666-
if not servers:
667-
raise ValueError("servers must be a non-empty list")
668-
669-
670670
def _validate_renew_ratio(ratio: float) -> None:
671671
if not 0 < ratio < 1:
672672
raise ValueError("renew_ratio must be between 0 and 1 (exclusive)")

src/dflockd_client/sharding.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,30 @@
1010
ShardingStrategy = Callable[[str, int], int]
1111

1212
DEFAULT_SERVERS: tuple[tuple[str, int], ...] = (("127.0.0.1", 6388),)
13+
MAX_TCP_PORT = 65535
14+
15+
16+
def _validate_server_endpoint(host: object, port: object) -> tuple[str, int]:
17+
if not isinstance(host, str):
18+
raise TypeError("server host must be a string")
19+
if host == "":
20+
raise ValueError("server host must not be empty")
21+
if any(c in host for c in (" ", "\t", "\n", "\r")):
22+
raise ValueError("server host must not contain whitespace")
23+
if isinstance(port, bool) or not isinstance(port, int):
24+
raise TypeError("server port must be an integer")
25+
if not 0 < port <= MAX_TCP_PORT:
26+
raise ValueError(f"server port must be between 1 and {MAX_TCP_PORT}")
27+
return host, port
28+
29+
30+
def _validate_servers(servers: object) -> None:
31+
if not isinstance(servers, list | tuple) or not servers:
32+
raise ValueError("servers must be a non-empty list or tuple")
33+
for server in servers:
34+
if not isinstance(server, tuple) or len(server) != 2:
35+
raise TypeError("servers must contain (host, port) tuples")
36+
_validate_server_endpoint(server[0], server[1])
1337

1438

1539
def _validate_shard_index(index: object, num_servers: int) -> int:

tests/test_async_unit.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -381,6 +381,21 @@ def test_empty_servers(self):
381381
with pytest.raises(ValueError, match="non-empty"):
382382
da.DistributedLock(key="k", servers=[])
383383

384+
def test_invalid_server_shape_raises_at_construction(self):
385+
with pytest.raises(TypeError, match="\\(host, port\\)"):
386+
da.DistributedLock(
387+
key="k",
388+
servers=("127.0.0.1", 6388), # type: ignore[arg-type]
389+
)
390+
391+
def test_invalid_server_port_raises_at_construction(self):
392+
with pytest.raises(ValueError, match="server port"):
393+
da.DistributedLock(key="k", servers=[("127.0.0.1", 70000)])
394+
395+
def test_invalid_connect_timeout_raises_at_construction(self):
396+
with pytest.raises(ValueError, match="connect_timeout_s"):
397+
da.DistributedLock(key="k", connect_timeout_s=0)
398+
384399
def test_renew_ratio_out_of_range(self):
385400
with pytest.raises(ValueError, match="renew_ratio"):
386401
da.DistributedLock(key="k", renew_ratio=1.0)
@@ -465,6 +480,16 @@ async def _proto_renew(self, conn: da.AsyncConn, token: str) -> int:
465480
assert lock.lease == 0
466481
assert cast(FakeConn, conn).closed is True
467482

483+
def test_renew_failure_log_redacts_token(self, caplog):
484+
lock = da.DistributedLock(key="k")
485+
lock.token = "0123456789abcdef0123456789abcdef"
486+
487+
with caplog.at_level("ERROR", logger="dflockd_client"):
488+
lock._log_renew_failure()
489+
490+
assert "01234567..." in caplog.text
491+
assert lock.token not in caplog.text
492+
468493

469494
class TestAsyncLifecycleCancellation:
470495
async def test_release_cancellation_still_clears_state(self):

tests/test_protocol.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,29 @@ def test_rejects_too_long(self):
189189
proto.validate_auth_token("x" * (proto.MAX_AUTH_TOKEN_BYTES + 1))
190190

191191

192+
class TestValidateConnectTimeoutS:
193+
def test_accepts_int_or_float(self):
194+
proto.validate_connect_timeout_s(1)
195+
proto.validate_connect_timeout_s(0.5)
196+
197+
def test_rejects_non_number(self):
198+
with pytest.raises(TypeError, match="must be a number"):
199+
proto.validate_connect_timeout_s("1") # type: ignore[arg-type]
200+
201+
def test_rejects_bool(self):
202+
with pytest.raises(TypeError, match="must be a number"):
203+
proto.validate_connect_timeout_s(True) # type: ignore[arg-type]
204+
205+
@pytest.mark.parametrize("bad", [0, -1, float("inf"), float("nan")])
206+
def test_rejects_non_positive_or_non_finite(self, bad):
207+
with pytest.raises(ValueError):
208+
proto.validate_connect_timeout_s(bad)
209+
210+
def test_rejects_too_large(self):
211+
with pytest.raises(ValueError, match="too large"):
212+
proto.validate_connect_timeout_s(proto.MAX_PROTOCOL_SECONDS + 1)
213+
214+
192215
# ---------------------------------------------------------------------------
193216
# fence_from_token
194217
# ---------------------------------------------------------------------------
@@ -245,6 +268,20 @@ def test_is_re_exported_from_package(self):
245268
assert fence_from_token("0000000000000001" + "0" * 16) == 1
246269

247270

271+
class TestTokenForLog:
272+
def test_none(self):
273+
assert proto.token_for_log(None) == "<none>"
274+
275+
def test_short_token(self):
276+
assert proto.token_for_log("short") == "<redacted>"
277+
278+
def test_long_token_redacts_tail(self):
279+
token = "0123456789abcdef0123456789abcdef"
280+
label = proto.token_for_log(token)
281+
assert label == "01234567..."
282+
assert token not in label
283+
284+
248285
# ---------------------------------------------------------------------------
249286
# encode_lines
250287
# ---------------------------------------------------------------------------

tests/test_sharding.py

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,12 @@
22

33
import pytest
44

5-
from dflockd_client.sharding import DEFAULT_SERVERS, stable_hash_shard
5+
from dflockd_client.sharding import (
6+
DEFAULT_SERVERS,
7+
_validate_server_endpoint,
8+
_validate_servers,
9+
stable_hash_shard,
10+
)
611

712

813
class TestStableHashShard:
@@ -31,3 +36,43 @@ def test_distribution(self):
3136

3237
def test_default_servers():
3338
assert DEFAULT_SERVERS == (("127.0.0.1", 6388),)
39+
40+
41+
class TestValidateServerEndpoint:
42+
def test_valid(self):
43+
assert _validate_server_endpoint("127.0.0.1", 6388) == ("127.0.0.1", 6388)
44+
45+
@pytest.mark.parametrize("host", ["", "bad host", "bad\nhost"])
46+
def test_rejects_bad_host(self, host):
47+
with pytest.raises(ValueError):
48+
_validate_server_endpoint(host, 6388)
49+
50+
def test_rejects_non_string_host(self):
51+
with pytest.raises(TypeError):
52+
_validate_server_endpoint(127001, 6388)
53+
54+
@pytest.mark.parametrize("port", [0, -1, 65536])
55+
def test_rejects_out_of_range_port(self, port):
56+
with pytest.raises(ValueError):
57+
_validate_server_endpoint("127.0.0.1", port)
58+
59+
@pytest.mark.parametrize("port", [True, "6388"])
60+
def test_rejects_non_int_port(self, port):
61+
with pytest.raises(TypeError):
62+
_validate_server_endpoint("127.0.0.1", port)
63+
64+
65+
class TestValidateServers:
66+
def test_valid_list(self):
67+
_validate_servers([("127.0.0.1", 6388)])
68+
69+
def test_valid_tuple(self):
70+
_validate_servers((("127.0.0.1", 6388),))
71+
72+
def test_rejects_empty(self):
73+
with pytest.raises(ValueError, match="non-empty"):
74+
_validate_servers([])
75+
76+
def test_rejects_single_endpoint_tuple(self):
77+
with pytest.raises(TypeError, match="\\(host, port\\)"):
78+
_validate_servers(("127.0.0.1", 6388))

tests/test_sync_unit.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -312,6 +312,21 @@ def test_empty_servers_raises(self):
312312
with pytest.raises(ValueError, match="non-empty"):
313313
ds.DistributedLock(key="k", servers=[])
314314

315+
def test_invalid_server_shape_raises_at_construction(self):
316+
with pytest.raises(TypeError, match="\\(host, port\\)"):
317+
ds.DistributedLock(
318+
key="k",
319+
servers=("127.0.0.1", 6388), # type: ignore[arg-type]
320+
)
321+
322+
def test_invalid_server_port_raises_at_construction(self):
323+
with pytest.raises(ValueError, match="server port"):
324+
ds.DistributedLock(key="k", servers=[("127.0.0.1", 70000)])
325+
326+
def test_invalid_connect_timeout_raises_at_construction(self):
327+
with pytest.raises(ValueError, match="connect_timeout_s"):
328+
ds.DistributedLock(key="k", connect_timeout_s=0)
329+
315330
def test_renew_ratio_out_of_range(self):
316331
with pytest.raises(ValueError, match="renew_ratio"):
317332
ds.DistributedLock(key="k", renew_ratio=0)
@@ -432,6 +447,16 @@ def test_stopping_renew_failure_still_drops_broken_connection(self):
432447
assert lock.lease == 0
433448
assert cast(FakeConn, conn).closed is True
434449

450+
def test_renew_failure_log_redacts_token(self, caplog):
451+
lock = ds.DistributedLock(key="k")
452+
lock.token = "0123456789abcdef0123456789abcdef"
453+
454+
with caplog.at_level("ERROR", logger="dflockd_client"):
455+
lock._log_renew_failure()
456+
457+
assert "01234567..." in caplog.text
458+
assert lock.token not in caplog.text
459+
435460
def test_old_renew_thread_stops_after_stop_event_replaced(self, monkeypatch):
436461
first_started = threading.Event()
437462
release_first = threading.Event()

0 commit comments

Comments
 (0)