Skip to content

Commit 222b498

Browse files
zeevdrclaude
andcommitted
feat(client): add check_version flag for lazy compatibility check on first RPC
Add `check_version: bool = False` constructor parameter to both `ConfigClient` and `AsyncConfigClient`. When enabled, calls `check_compatibility()` once before the first RPC and raises `IncompatibleServerError` on mismatch. Subsequent calls skip the check via a `_version_checked` flag. Closes #63 Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 9c3739c commit 222b498

3 files changed

Lines changed: 116 additions & 0 deletions

File tree

sdk/src/opendecree/async_client.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ def __init__(
5353
credentials: grpc.ChannelCredentials | None = None,
5454
timeout: float = 10.0,
5555
retry: RetryConfig | None = None,
56+
check_version: bool = False,
5657
) -> None:
5758
"""Create a new AsyncConfigClient.
5859
@@ -72,9 +73,14 @@ def __init__(
7273
timeout: Default per-RPC timeout in seconds. Defaults to 10.
7374
retry: Retry configuration. Defaults to ``RetryConfig()``.
7475
Pass ``None`` to disable retry.
76+
check_version: When True, run :meth:`check_compatibility` lazily
77+
on the first RPC call. Raises :exc:`IncompatibleServerError`
78+
if the server version is outside the supported range.
7579
"""
7680
self._timeout = timeout
7781
self._retry = retry if retry is not None else RetryConfig()
82+
self._check_version = check_version
83+
self._version_checked = False
7884

7985
tls_active = credentials is not None or not insecure
8086
if token and not tls_active:
@@ -151,6 +157,11 @@ async def check_compatibility(self) -> None:
151157
sv = await self.get_server_version()
152158
check_version_compatible(sv.version)
153159

160+
async def _ensure_version_checked(self) -> None:
161+
if self._check_version and not self._version_checked:
162+
self._version_checked = True
163+
await self.check_compatibility()
164+
154165
def _metadata(self) -> list[tuple[str, str]]:
155166
"""Return auth metadata for each call."""
156167
return list(self._auth_metadata)
@@ -211,6 +222,7 @@ async def get(
211222
TypeMismatchError: If the value cannot be converted to the requested type.
212223
"""
213224
target_type = value_type or str
225+
await self._ensure_version_checked()
214226

215227
async def _call() -> object:
216228
resp = await self._stub.GetField(
@@ -238,6 +250,8 @@ async def get_all(self, tenant_id: str) -> dict[str, str]:
238250
NotFoundError: If the tenant does not exist.
239251
"""
240252

253+
await self._ensure_version_checked()
254+
241255
async def _call() -> dict[str, str]:
242256
resp = await self._stub.GetConfig(
243257
self._pb2.GetConfigRequest(tenant_id=tenant_id),
@@ -289,6 +303,7 @@ async def set(
289303
ChecksumMismatchError: If ``expected_checksum`` is set and does not match.
290304
"""
291305
retry_cfg = self._retry if idempotency_key is not None else write_safe_config(self._retry)
306+
await self._ensure_version_checked()
292307

293308
async def _call() -> None:
294309
await self._stub.SetField(
@@ -335,6 +350,7 @@ async def set_many(
335350
ChecksumMismatchError: If any ``expected_checksum`` does not match.
336351
"""
337352
retry_cfg = self._retry if idempotency_key is not None else write_safe_config(self._retry)
353+
await self._ensure_version_checked()
338354

339355
async def _call() -> None:
340356
proto_updates = [
@@ -382,6 +398,7 @@ async def set_null(
382398
LockedError: If the field is locked.
383399
"""
384400
retry_cfg = self._retry if idempotency_key is not None else write_safe_config(self._retry)
401+
await self._ensure_version_checked()
385402

386403
async def _call() -> None:
387404
await self._stub.SetField(

sdk/src/opendecree/client.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ def __init__(
5656
credentials: grpc.ChannelCredentials | None = None,
5757
timeout: float = 10.0,
5858
retry: RetryConfig | None = None,
59+
check_version: bool = False,
5960
) -> None:
6061
"""Create a new ConfigClient.
6162
@@ -75,9 +76,14 @@ def __init__(
7576
timeout: Default per-RPC timeout in seconds. Defaults to 10.
7677
retry: Retry configuration. Defaults to ``RetryConfig()``.
7778
Pass ``None`` to disable retry.
79+
check_version: When True, run :meth:`check_compatibility` lazily
80+
on the first RPC call. Raises :exc:`IncompatibleServerError`
81+
if the server version is outside the supported range.
7882
"""
7983
self._timeout = timeout
8084
self._retry = retry if retry is not None else RetryConfig()
85+
self._check_version = check_version
86+
self._version_checked = False
8187

8288
tls_active = credentials is not None or not insecure
8389
if token and not tls_active:
@@ -170,6 +176,11 @@ def check_compatibility(self) -> None:
170176
"""
171177
check_version_compatible(self.get_server_version().version)
172178

179+
def _ensure_version_checked(self) -> None:
180+
if self._check_version and not self._version_checked:
181+
self._version_checked = True
182+
self.check_compatibility()
183+
173184
# --- get() with @overload for type safety ---
174185

175186
@overload
@@ -224,6 +235,7 @@ def get(
224235
TypeMismatchError: If the value cannot be converted to the requested type.
225236
"""
226237
target_type = value_type or str
238+
self._ensure_version_checked()
227239

228240
def _call() -> object:
229241
resp = self._stub.GetField(
@@ -250,6 +262,8 @@ def get_all(self, tenant_id: str) -> dict[str, str]:
250262
NotFoundError: If the tenant does not exist.
251263
"""
252264

265+
self._ensure_version_checked()
266+
253267
def _call() -> dict[str, str]:
254268
resp = self._stub.GetConfig(
255269
self._pb2.GetConfigRequest(tenant_id=tenant_id),
@@ -300,6 +314,7 @@ def set(
300314
ChecksumMismatchError: If ``expected_checksum`` is set and does not match.
301315
"""
302316
retry_cfg = self._retry if idempotency_key is not None else write_safe_config(self._retry)
317+
self._ensure_version_checked()
303318

304319
def _call() -> None:
305320
self._stub.SetField(
@@ -345,6 +360,7 @@ def set_many(
345360
ChecksumMismatchError: If any ``expected_checksum`` does not match.
346361
"""
347362
retry_cfg = self._retry if idempotency_key is not None else write_safe_config(self._retry)
363+
self._ensure_version_checked()
348364

349365
def _call() -> None:
350366
proto_updates = [
@@ -391,6 +407,7 @@ def set_null(
391407
LockedError: If the field is locked.
392408
"""
393409
retry_cfg = self._retry if idempotency_key is not None else write_safe_config(self._retry)
410+
self._ensure_version_checked()
394411

395412
def _call() -> None:
396413
self._stub.SetField(

sdk/tests/test_compat.py

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -212,3 +212,85 @@ def test_client_check_compatibility_fails():
212212

213213
with pytest.raises(IncompatibleServerError):
214214
client.check_compatibility()
215+
216+
217+
# --- check_version ctor flag ---
218+
219+
220+
def _make_client_with_version(server_version: str, check_version: bool = True):
221+
"""Return a ConfigClient.__new__ instance wired with a mock server version."""
222+
from opendecree import ConfigClient
223+
224+
client = ConfigClient.__new__(ConfigClient)
225+
client._timeout = 5.0
226+
client._check_version = check_version
227+
client._version_checked = False
228+
client._server_version = ServerVersion(version=server_version, commit="abc")
229+
client._version_stub = MagicMock()
230+
client._version_pb2 = MagicMock()
231+
return client
232+
233+
234+
def test_ensure_version_checked_runs_once():
235+
client = _make_client_with_version("0.3.1")
236+
with patch.object(client, "check_compatibility") as mock_check:
237+
client._ensure_version_checked()
238+
client._ensure_version_checked()
239+
mock_check.assert_called_once()
240+
241+
242+
def test_ensure_version_checked_noop_when_disabled():
243+
client = _make_client_with_version("0.3.1", check_version=False)
244+
with patch.object(client, "check_compatibility") as mock_check:
245+
client._ensure_version_checked()
246+
mock_check.assert_not_called()
247+
248+
249+
def test_ensure_version_checked_raises_on_incompatible():
250+
client = _make_client_with_version("0.1.0")
251+
with pytest.raises(IncompatibleServerError):
252+
client._ensure_version_checked()
253+
254+
255+
@pytest.mark.asyncio
256+
async def test_async_ensure_version_checked_runs_once():
257+
from opendecree import AsyncConfigClient
258+
259+
client = AsyncConfigClient.__new__(AsyncConfigClient)
260+
client._timeout = 5.0
261+
client._check_version = True
262+
client._version_checked = False
263+
client._server_version = ServerVersion(version="0.3.1", commit="abc")
264+
client._version_stub = MagicMock()
265+
client._version_pb2 = MagicMock()
266+
267+
call_count = 0
268+
269+
async def fake_check():
270+
nonlocal call_count
271+
call_count += 1
272+
273+
client.check_compatibility = fake_check
274+
await client._ensure_version_checked()
275+
await client._ensure_version_checked()
276+
assert call_count == 1
277+
278+
279+
@pytest.mark.asyncio
280+
async def test_async_ensure_version_checked_noop_when_disabled():
281+
from opendecree import AsyncConfigClient
282+
283+
client = AsyncConfigClient.__new__(AsyncConfigClient)
284+
client._timeout = 5.0
285+
client._check_version = False
286+
client._version_checked = False
287+
288+
called = False
289+
290+
async def fake_check():
291+
nonlocal called
292+
called = True
293+
294+
client.check_compatibility = fake_check
295+
await client._ensure_version_checked()
296+
assert not called

0 commit comments

Comments
 (0)