From 222b4987f1048cf1b616da04a8ccc7e4da87ccc8 Mon Sep 17 00:00:00 2001 From: zeevdr Date: Mon, 25 May 2026 07:55:56 +0300 Subject: [PATCH] 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 --- sdk/src/opendecree/async_client.py | 17 +++++++ sdk/src/opendecree/client.py | 17 +++++++ sdk/tests/test_compat.py | 82 ++++++++++++++++++++++++++++++ 3 files changed, 116 insertions(+) diff --git a/sdk/src/opendecree/async_client.py b/sdk/src/opendecree/async_client.py index a80f7ae..3f3ba7b 100644 --- a/sdk/src/opendecree/async_client.py +++ b/sdk/src/opendecree/async_client.py @@ -53,6 +53,7 @@ def __init__( credentials: grpc.ChannelCredentials | None = None, timeout: float = 10.0, retry: RetryConfig | None = None, + check_version: bool = False, ) -> None: """Create a new AsyncConfigClient. @@ -72,9 +73,14 @@ def __init__( timeout: Default per-RPC timeout in seconds. Defaults to 10. retry: Retry configuration. Defaults to ``RetryConfig()``. Pass ``None`` to disable retry. + check_version: When True, run :meth:`check_compatibility` lazily + on the first RPC call. Raises :exc:`IncompatibleServerError` + if the server version is outside the supported range. """ self._timeout = timeout self._retry = retry if retry is not None else RetryConfig() + self._check_version = check_version + self._version_checked = False tls_active = credentials is not None or not insecure if token and not tls_active: @@ -151,6 +157,11 @@ async def check_compatibility(self) -> None: sv = await self.get_server_version() check_version_compatible(sv.version) + async def _ensure_version_checked(self) -> None: + if self._check_version and not self._version_checked: + self._version_checked = True + await self.check_compatibility() + def _metadata(self) -> list[tuple[str, str]]: """Return auth metadata for each call.""" return list(self._auth_metadata) @@ -211,6 +222,7 @@ async def get( TypeMismatchError: If the value cannot be converted to the requested type. """ target_type = value_type or str + await self._ensure_version_checked() async def _call() -> object: resp = await self._stub.GetField( @@ -238,6 +250,8 @@ async def get_all(self, tenant_id: str) -> dict[str, str]: NotFoundError: If the tenant does not exist. """ + await self._ensure_version_checked() + async def _call() -> dict[str, str]: resp = await self._stub.GetConfig( self._pb2.GetConfigRequest(tenant_id=tenant_id), @@ -289,6 +303,7 @@ async def set( ChecksumMismatchError: If ``expected_checksum`` is set and does not match. """ retry_cfg = self._retry if idempotency_key is not None else write_safe_config(self._retry) + await self._ensure_version_checked() async def _call() -> None: await self._stub.SetField( @@ -335,6 +350,7 @@ async def set_many( ChecksumMismatchError: If any ``expected_checksum`` does not match. """ retry_cfg = self._retry if idempotency_key is not None else write_safe_config(self._retry) + await self._ensure_version_checked() async def _call() -> None: proto_updates = [ @@ -382,6 +398,7 @@ async def set_null( LockedError: If the field is locked. """ retry_cfg = self._retry if idempotency_key is not None else write_safe_config(self._retry) + await self._ensure_version_checked() async def _call() -> None: await self._stub.SetField( diff --git a/sdk/src/opendecree/client.py b/sdk/src/opendecree/client.py index 9a614b1..bd29b28 100644 --- a/sdk/src/opendecree/client.py +++ b/sdk/src/opendecree/client.py @@ -56,6 +56,7 @@ def __init__( credentials: grpc.ChannelCredentials | None = None, timeout: float = 10.0, retry: RetryConfig | None = None, + check_version: bool = False, ) -> None: """Create a new ConfigClient. @@ -75,9 +76,14 @@ def __init__( timeout: Default per-RPC timeout in seconds. Defaults to 10. retry: Retry configuration. Defaults to ``RetryConfig()``. Pass ``None`` to disable retry. + check_version: When True, run :meth:`check_compatibility` lazily + on the first RPC call. Raises :exc:`IncompatibleServerError` + if the server version is outside the supported range. """ self._timeout = timeout self._retry = retry if retry is not None else RetryConfig() + self._check_version = check_version + self._version_checked = False tls_active = credentials is not None or not insecure if token and not tls_active: @@ -170,6 +176,11 @@ def check_compatibility(self) -> None: """ check_version_compatible(self.get_server_version().version) + def _ensure_version_checked(self) -> None: + if self._check_version and not self._version_checked: + self._version_checked = True + self.check_compatibility() + # --- get() with @overload for type safety --- @overload @@ -224,6 +235,7 @@ def get( TypeMismatchError: If the value cannot be converted to the requested type. """ target_type = value_type or str + self._ensure_version_checked() def _call() -> object: resp = self._stub.GetField( @@ -250,6 +262,8 @@ def get_all(self, tenant_id: str) -> dict[str, str]: NotFoundError: If the tenant does not exist. """ + self._ensure_version_checked() + def _call() -> dict[str, str]: resp = self._stub.GetConfig( self._pb2.GetConfigRequest(tenant_id=tenant_id), @@ -300,6 +314,7 @@ def set( ChecksumMismatchError: If ``expected_checksum`` is set and does not match. """ retry_cfg = self._retry if idempotency_key is not None else write_safe_config(self._retry) + self._ensure_version_checked() def _call() -> None: self._stub.SetField( @@ -345,6 +360,7 @@ def set_many( ChecksumMismatchError: If any ``expected_checksum`` does not match. """ retry_cfg = self._retry if idempotency_key is not None else write_safe_config(self._retry) + self._ensure_version_checked() def _call() -> None: proto_updates = [ @@ -391,6 +407,7 @@ def set_null( LockedError: If the field is locked. """ retry_cfg = self._retry if idempotency_key is not None else write_safe_config(self._retry) + self._ensure_version_checked() def _call() -> None: self._stub.SetField( diff --git a/sdk/tests/test_compat.py b/sdk/tests/test_compat.py index f595672..da8ab21 100644 --- a/sdk/tests/test_compat.py +++ b/sdk/tests/test_compat.py @@ -212,3 +212,85 @@ def test_client_check_compatibility_fails(): with pytest.raises(IncompatibleServerError): client.check_compatibility() + + +# --- check_version ctor flag --- + + +def _make_client_with_version(server_version: str, check_version: bool = True): + """Return a ConfigClient.__new__ instance wired with a mock server version.""" + from opendecree import ConfigClient + + client = ConfigClient.__new__(ConfigClient) + client._timeout = 5.0 + client._check_version = check_version + client._version_checked = False + client._server_version = ServerVersion(version=server_version, commit="abc") + client._version_stub = MagicMock() + client._version_pb2 = MagicMock() + return client + + +def test_ensure_version_checked_runs_once(): + client = _make_client_with_version("0.3.1") + with patch.object(client, "check_compatibility") as mock_check: + client._ensure_version_checked() + client._ensure_version_checked() + mock_check.assert_called_once() + + +def test_ensure_version_checked_noop_when_disabled(): + client = _make_client_with_version("0.3.1", check_version=False) + with patch.object(client, "check_compatibility") as mock_check: + client._ensure_version_checked() + mock_check.assert_not_called() + + +def test_ensure_version_checked_raises_on_incompatible(): + client = _make_client_with_version("0.1.0") + with pytest.raises(IncompatibleServerError): + client._ensure_version_checked() + + +@pytest.mark.asyncio +async def test_async_ensure_version_checked_runs_once(): + from opendecree import AsyncConfigClient + + client = AsyncConfigClient.__new__(AsyncConfigClient) + client._timeout = 5.0 + client._check_version = True + client._version_checked = False + client._server_version = ServerVersion(version="0.3.1", commit="abc") + client._version_stub = MagicMock() + client._version_pb2 = MagicMock() + + call_count = 0 + + async def fake_check(): + nonlocal call_count + call_count += 1 + + client.check_compatibility = fake_check + await client._ensure_version_checked() + await client._ensure_version_checked() + assert call_count == 1 + + +@pytest.mark.asyncio +async def test_async_ensure_version_checked_noop_when_disabled(): + from opendecree import AsyncConfigClient + + client = AsyncConfigClient.__new__(AsyncConfigClient) + client._timeout = 5.0 + client._check_version = False + client._version_checked = False + + called = False + + async def fake_check(): + nonlocal called + called = True + + client.check_compatibility = fake_check + await client._ensure_version_checked() + assert not called