diff --git a/sdk/src/opendecree/__init__.py b/sdk/src/opendecree/__init__.py index e78966d..0f8a1e8 100644 --- a/sdk/src/opendecree/__init__.py +++ b/sdk/src/opendecree/__init__.py @@ -23,7 +23,7 @@ TypeMismatchError, UnavailableError, ) -from opendecree.types import Change, ConfigValue, ServerVersion +from opendecree.types import Change, ConfigValue, FieldUpdate, ServerVersion from opendecree.watcher import ConfigWatcher, WatchedField __all__ = [ @@ -39,6 +39,7 @@ "ConfigValue", "ConfigWatcher", "DecreeError", + "FieldUpdate", "IncompatibleServerError", "InvalidArgumentError", "LockedError", diff --git a/sdk/src/opendecree/async_client.py b/sdk/src/opendecree/async_client.py index df9dfa1..cd3013d 100644 --- a/sdk/src/opendecree/async_client.py +++ b/sdk/src/opendecree/async_client.py @@ -27,7 +27,7 @@ process_get_response, ) from opendecree.errors import map_grpc_error -from opendecree.types import ServerVersion +from opendecree.types import FieldUpdate, ServerVersion class AsyncConfigClient: @@ -233,6 +233,9 @@ async def set( field_path: str, value: str, *, + description: str | None = None, + value_description: str | None = None, + expected_checksum: str | None = None, idempotency_key: str | None = None, ) -> None: """Set a config value. @@ -244,6 +247,10 @@ async def set( tenant_id: Tenant UUID. field_path: Dot-separated field path (e.g., ``"payments.fee"``). value: The value as a string. + description: Optional version-level description for the audit log. + value_description: Optional description stored with this specific value. + expected_checksum: When set, the server rejects the write if the + current value's checksum does not match (optimistic concurrency). idempotency_key: When provided, the request is retried on ``DEADLINE_EXCEEDED`` in addition to ``UNAVAILABLE``. Use only when the write is safe to apply more than once (e.g., the value @@ -255,6 +262,7 @@ async def set( NotFoundError: If the field does not exist in the schema. LockedError: If the field is locked. InvalidArgumentError: If the value fails validation. + 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) @@ -264,6 +272,9 @@ async def _call() -> None: tenant_id=tenant_id, field_path=field_path, value=make_string_typed_value(value), + description=description, + value_description=value_description, + expected_checksum=expected_checksum, ), timeout=self._timeout, metadata=self._metadata(), @@ -277,17 +288,18 @@ async def _call() -> None: async def set_many( self, tenant_id: str, - values: dict[str, str], + updates: list[FieldUpdate], *, - description: str = "", + description: str | None = None, idempotency_key: str | None = None, ) -> None: """Atomically set multiple config values. Args: tenant_id: Tenant UUID. - values: Dict mapping field paths to string values. - description: Optional description for the audit log. + updates: List of :class:`FieldUpdate` objects, each carrying a + field path, value, and optional per-field metadata. + description: Optional version-level description for the audit log. idempotency_key: When provided, the request is retried on ``DEADLINE_EXCEEDED`` in addition to ``UNAVAILABLE``. See ``set()`` for details on retry semantics. @@ -296,21 +308,24 @@ async def set_many( NotFoundError: If a field does not exist in the schema. LockedError: If any field is locked. InvalidArgumentError: If any value fails validation. + ChecksumMismatchError: If any ``expected_checksum`` does not match. """ retry_cfg = self._retry if idempotency_key is not None else write_safe_config(self._retry) async def _call() -> None: - updates = [ + proto_updates = [ self._pb2.FieldUpdate( - field_path=fp, - value=make_string_typed_value(v), + field_path=u.field_path, + value=make_string_typed_value(u.value), + expected_checksum=u.expected_checksum, + value_description=u.value_description, ) - for fp, v in values.items() + for u in updates ] await self._stub.SetFields( self._pb2.SetFieldsRequest( tenant_id=tenant_id, - updates=updates, + updates=proto_updates, description=description, ), timeout=self._timeout, diff --git a/sdk/src/opendecree/client.py b/sdk/src/opendecree/client.py index 3b8865f..cdef25f 100644 --- a/sdk/src/opendecree/client.py +++ b/sdk/src/opendecree/client.py @@ -30,7 +30,7 @@ process_get_response, ) from opendecree.errors import map_grpc_error -from opendecree.types import ServerVersion +from opendecree.types import FieldUpdate, ServerVersion class ConfigClient: @@ -235,6 +235,9 @@ def set( field_path: str, value: str, *, + description: str | None = None, + value_description: str | None = None, + expected_checksum: str | None = None, idempotency_key: str | None = None, ) -> None: """Set a config value. @@ -246,6 +249,10 @@ def set( tenant_id: Tenant UUID. field_path: Dot-separated field path (e.g., ``"payments.fee"``). value: The value as a string. + description: Optional version-level description for the audit log. + value_description: Optional description stored with this specific value. + expected_checksum: When set, the server rejects the write if the + current value's checksum does not match (optimistic concurrency). idempotency_key: When provided, the request is retried on ``DEADLINE_EXCEEDED`` in addition to ``UNAVAILABLE``. Use only when the write is safe to apply more than once (e.g., the value @@ -257,6 +264,7 @@ def set( NotFoundError: If the field does not exist in the schema. LockedError: If the field is locked. InvalidArgumentError: If the value fails validation. + 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) @@ -266,6 +274,9 @@ def _call() -> None: tenant_id=tenant_id, field_path=field_path, value=make_string_typed_value(value), + description=description, + value_description=value_description, + expected_checksum=expected_checksum, ), timeout=self._timeout, ) @@ -278,17 +289,18 @@ def _call() -> None: def set_many( self, tenant_id: str, - values: dict[str, str], + updates: list[FieldUpdate], *, - description: str = "", + description: str | None = None, idempotency_key: str | None = None, ) -> None: """Atomically set multiple config values. Args: tenant_id: Tenant UUID. - values: Dict mapping field paths to string values. - description: Optional description for the audit log. + updates: List of :class:`FieldUpdate` objects, each carrying a + field path, value, and optional per-field metadata. + description: Optional version-level description for the audit log. idempotency_key: When provided, the request is retried on ``DEADLINE_EXCEEDED`` in addition to ``UNAVAILABLE``. See ``set()`` for details on retry semantics. @@ -297,21 +309,24 @@ def set_many( NotFoundError: If a field does not exist in the schema. LockedError: If any field is locked. InvalidArgumentError: If any value fails validation. + ChecksumMismatchError: If any ``expected_checksum`` does not match. """ retry_cfg = self._retry if idempotency_key is not None else write_safe_config(self._retry) def _call() -> None: - updates = [ + proto_updates = [ self._pb2.FieldUpdate( - field_path=fp, - value=make_string_typed_value(v), + field_path=u.field_path, + value=make_string_typed_value(u.value), + expected_checksum=u.expected_checksum, + value_description=u.value_description, ) - for fp, v in values.items() + for u in updates ] self._stub.SetFields( self._pb2.SetFieldsRequest( tenant_id=tenant_id, - updates=updates, + updates=proto_updates, description=description, ), timeout=self._timeout, diff --git a/sdk/src/opendecree/types.py b/sdk/src/opendecree/types.py index 8df0f36..c82bb97 100644 --- a/sdk/src/opendecree/types.py +++ b/sdk/src/opendecree/types.py @@ -44,6 +44,24 @@ class Change: changed_by: str = "" +@dataclass(frozen=True, slots=True) +class FieldUpdate: + """A single field update for use with :meth:`ConfigClient.set_many`. + + Attributes: + field_path: Dot-separated field path (e.g., ``"payments.fee"``). + value: The value as a string. + expected_checksum: When set, the server rejects the write if the + current value's checksum does not match (optimistic concurrency). + value_description: Optional description stored with this specific value. + """ + + field_path: str + value: str + expected_checksum: str | None = None + value_description: str | None = None + + @dataclass(frozen=True, slots=True) class ServerVersion: """Server version information from the ServerService. diff --git a/sdk/tests/test_async_client.py b/sdk/tests/test_async_client.py index 7444e05..9dd1714 100644 --- a/sdk/tests/test_async_client.py +++ b/sdk/tests/test_async_client.py @@ -8,6 +8,7 @@ from opendecree.async_client import AsyncConfigClient from opendecree.errors import DecreeError, NotFoundError, UnavailableError +from opendecree.types import FieldUpdate from tests.conftest import FakeRpcError @@ -135,12 +136,43 @@ async def test_set(self): await client.set("t1", "payments.fee", "0.5%") client._stub.SetField.assert_called_once() + @pytest.mark.asyncio + async def test_set_with_metadata(self): + client = self._make_client() + client._stub.SetField = AsyncMock(return_value=MagicMock()) + + await client.set( + "t1", + "payments.fee", + "0.5%", + description="fee increase", + value_description="Q2 rate", + expected_checksum="abc123", + ) + req = client._stub.SetField.call_args[0][0] + assert req.description == "fee increase" + assert req.value_description == "Q2 rate" + assert req.expected_checksum == "abc123" + @pytest.mark.asyncio async def test_set_many(self): client = self._make_client() client._stub.SetFields = AsyncMock(return_value=MagicMock()) - await client.set_many("t1", {"a": "1", "b": "2"}, description="batch") + updates = [FieldUpdate("a", "1"), FieldUpdate("b", "2")] + await client.set_many("t1", updates, description="batch") + client._stub.SetFields.assert_called_once() + + @pytest.mark.asyncio + async def test_set_many_with_per_field_metadata(self): + client = self._make_client() + client._stub.SetFields = AsyncMock(return_value=MagicMock()) + + updates = [ + FieldUpdate("a", "1", expected_checksum="cs1", value_description="first"), + FieldUpdate("b", "2"), + ] + await client.set_many("t1", updates) client._stub.SetFields.assert_called_once() @pytest.mark.asyncio @@ -176,7 +208,7 @@ async def test_set_many_grpc_error(self): client._stub.SetFields = AsyncMock(side_effect=err) with pytest.raises(UnavailableError): - await client.set_many("t1", {"a": "1"}) + await client.set_many("t1", [FieldUpdate("a", "1")]) @pytest.mark.asyncio async def test_set_null_grpc_error(self): @@ -218,7 +250,7 @@ async def test_set_many_does_not_retry_on_deadline_exceeded(self): with patch("opendecree._retry.asyncio.sleep", new_callable=AsyncMock): with pytest.raises(DecreeError): - await client.set_many("t1", {"a": "1"}) + await client.set_many("t1", [FieldUpdate("a", "1")]) client._stub.SetFields.assert_called_once() diff --git a/sdk/tests/test_client.py b/sdk/tests/test_client.py index 88825f3..1ef9266 100644 --- a/sdk/tests/test_client.py +++ b/sdk/tests/test_client.py @@ -7,6 +7,7 @@ import opendecree from opendecree.errors import DecreeError, NotFoundError, UnavailableError +from opendecree.types import FieldUpdate from tests.conftest import FakeRpcError @@ -146,11 +147,40 @@ def test_set(self): client.set("t1", "payments.fee", "0.5%") client._stub.SetField.assert_called_once() + def test_set_with_metadata(self): + client = self._make_client() + client._stub.SetField.return_value = MagicMock() + + client.set( + "t1", + "payments.fee", + "0.5%", + description="fee increase", + value_description="Q2 rate", + expected_checksum="abc123", + ) + req = client._stub.SetField.call_args[0][0] + assert req.description == "fee increase" + assert req.value_description == "Q2 rate" + assert req.expected_checksum == "abc123" + def test_set_many(self): client = self._make_client() client._stub.SetFields.return_value = MagicMock() - client.set_many("t1", {"a": "1", "b": "2"}, description="batch") + updates = [FieldUpdate("a", "1"), FieldUpdate("b", "2")] + client.set_many("t1", updates, description="batch") + client._stub.SetFields.assert_called_once() + + def test_set_many_with_per_field_metadata(self): + client = self._make_client() + client._stub.SetFields.return_value = MagicMock() + + updates = [ + FieldUpdate("a", "1", expected_checksum="cs1", value_description="first"), + FieldUpdate("b", "2"), + ] + client.set_many("t1", updates) client._stub.SetFields.assert_called_once() def test_set_null(self): @@ -179,7 +209,7 @@ def test_set_many_grpc_error(self): client._stub.SetFields.side_effect = FakeRpcError(grpc.StatusCode.UNAVAILABLE, "down") with pytest.raises(UnavailableError): - client.set_many("t1", {"a": "1"}) + client.set_many("t1", [FieldUpdate("a", "1")]) def test_set_null_grpc_error(self): client = self._make_client() @@ -216,7 +246,7 @@ def test_set_many_does_not_retry_on_deadline_exceeded(self): with patch("opendecree._retry.time.sleep"): with pytest.raises(DecreeError): - client.set_many("t1", {"a": "1"}) + client.set_many("t1", [FieldUpdate("a", "1")]) client._stub.SetFields.assert_called_once()