Skip to content

Commit 8b51cec

Browse files
zeevdrclaude
andauthored
feat(sdk): add description, value_description, and expected_checksum to set() and set_many() (#79)
Exposes the three proto fields that were previously dropped at the Python SDK layer. `set()` gains `description`, `value_description`, and `expected_checksum` as keyword-only arguments. `set_many()` migrates from `dict[str, str]` to `list[FieldUpdate]`, a new frozen dataclass that carries per-field metadata (value, expected_checksum, value_description). `FieldUpdate` is exported from the top-level `opendecree` namespace. Closes #49 Co-authored-by: Claude <noreply@anthropic.com>
1 parent 111eb7e commit 8b51cec

6 files changed

Lines changed: 138 additions & 27 deletions

File tree

sdk/src/opendecree/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@
2323
TypeMismatchError,
2424
UnavailableError,
2525
)
26-
from opendecree.types import Change, ConfigValue, ServerVersion
26+
from opendecree.types import Change, ConfigValue, FieldUpdate, ServerVersion
2727
from opendecree.watcher import ConfigWatcher, WatchedField
2828

2929
__all__ = [
@@ -39,6 +39,7 @@
3939
"ConfigValue",
4040
"ConfigWatcher",
4141
"DecreeError",
42+
"FieldUpdate",
4243
"IncompatibleServerError",
4344
"InvalidArgumentError",
4445
"LockedError",

sdk/src/opendecree/async_client.py

Lines changed: 25 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@
2727
process_get_response,
2828
)
2929
from opendecree.errors import map_grpc_error
30-
from opendecree.types import ServerVersion
30+
from opendecree.types import FieldUpdate, ServerVersion
3131

3232

3333
class AsyncConfigClient:
@@ -233,6 +233,9 @@ async def set(
233233
field_path: str,
234234
value: str,
235235
*,
236+
description: str | None = None,
237+
value_description: str | None = None,
238+
expected_checksum: str | None = None,
236239
idempotency_key: str | None = None,
237240
) -> None:
238241
"""Set a config value.
@@ -244,6 +247,10 @@ async def set(
244247
tenant_id: Tenant UUID.
245248
field_path: Dot-separated field path (e.g., ``"payments.fee"``).
246249
value: The value as a string.
250+
description: Optional version-level description for the audit log.
251+
value_description: Optional description stored with this specific value.
252+
expected_checksum: When set, the server rejects the write if the
253+
current value's checksum does not match (optimistic concurrency).
247254
idempotency_key: When provided, the request is retried on
248255
``DEADLINE_EXCEEDED`` in addition to ``UNAVAILABLE``. Use only
249256
when the write is safe to apply more than once (e.g., the value
@@ -255,6 +262,7 @@ async def set(
255262
NotFoundError: If the field does not exist in the schema.
256263
LockedError: If the field is locked.
257264
InvalidArgumentError: If the value fails validation.
265+
ChecksumMismatchError: If ``expected_checksum`` is set and does not match.
258266
"""
259267
retry_cfg = self._retry if idempotency_key is not None else write_safe_config(self._retry)
260268

@@ -264,6 +272,9 @@ async def _call() -> None:
264272
tenant_id=tenant_id,
265273
field_path=field_path,
266274
value=make_string_typed_value(value),
275+
description=description,
276+
value_description=value_description,
277+
expected_checksum=expected_checksum,
267278
),
268279
timeout=self._timeout,
269280
metadata=self._metadata(),
@@ -277,17 +288,18 @@ async def _call() -> None:
277288
async def set_many(
278289
self,
279290
tenant_id: str,
280-
values: dict[str, str],
291+
updates: list[FieldUpdate],
281292
*,
282-
description: str = "",
293+
description: str | None = None,
283294
idempotency_key: str | None = None,
284295
) -> None:
285296
"""Atomically set multiple config values.
286297
287298
Args:
288299
tenant_id: Tenant UUID.
289-
values: Dict mapping field paths to string values.
290-
description: Optional description for the audit log.
300+
updates: List of :class:`FieldUpdate` objects, each carrying a
301+
field path, value, and optional per-field metadata.
302+
description: Optional version-level description for the audit log.
291303
idempotency_key: When provided, the request is retried on
292304
``DEADLINE_EXCEEDED`` in addition to ``UNAVAILABLE``. See
293305
``set()`` for details on retry semantics.
@@ -296,21 +308,24 @@ async def set_many(
296308
NotFoundError: If a field does not exist in the schema.
297309
LockedError: If any field is locked.
298310
InvalidArgumentError: If any value fails validation.
311+
ChecksumMismatchError: If any ``expected_checksum`` does not match.
299312
"""
300313
retry_cfg = self._retry if idempotency_key is not None else write_safe_config(self._retry)
301314

302315
async def _call() -> None:
303-
updates = [
316+
proto_updates = [
304317
self._pb2.FieldUpdate(
305-
field_path=fp,
306-
value=make_string_typed_value(v),
318+
field_path=u.field_path,
319+
value=make_string_typed_value(u.value),
320+
expected_checksum=u.expected_checksum,
321+
value_description=u.value_description,
307322
)
308-
for fp, v in values.items()
323+
for u in updates
309324
]
310325
await self._stub.SetFields(
311326
self._pb2.SetFieldsRequest(
312327
tenant_id=tenant_id,
313-
updates=updates,
328+
updates=proto_updates,
314329
description=description,
315330
),
316331
timeout=self._timeout,

sdk/src/opendecree/client.py

Lines changed: 25 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@
3030
process_get_response,
3131
)
3232
from opendecree.errors import map_grpc_error
33-
from opendecree.types import ServerVersion
33+
from opendecree.types import FieldUpdate, ServerVersion
3434

3535

3636
class ConfigClient:
@@ -235,6 +235,9 @@ def set(
235235
field_path: str,
236236
value: str,
237237
*,
238+
description: str | None = None,
239+
value_description: str | None = None,
240+
expected_checksum: str | None = None,
238241
idempotency_key: str | None = None,
239242
) -> None:
240243
"""Set a config value.
@@ -246,6 +249,10 @@ def set(
246249
tenant_id: Tenant UUID.
247250
field_path: Dot-separated field path (e.g., ``"payments.fee"``).
248251
value: The value as a string.
252+
description: Optional version-level description for the audit log.
253+
value_description: Optional description stored with this specific value.
254+
expected_checksum: When set, the server rejects the write if the
255+
current value's checksum does not match (optimistic concurrency).
249256
idempotency_key: When provided, the request is retried on
250257
``DEADLINE_EXCEEDED`` in addition to ``UNAVAILABLE``. Use only
251258
when the write is safe to apply more than once (e.g., the value
@@ -257,6 +264,7 @@ def set(
257264
NotFoundError: If the field does not exist in the schema.
258265
LockedError: If the field is locked.
259266
InvalidArgumentError: If the value fails validation.
267+
ChecksumMismatchError: If ``expected_checksum`` is set and does not match.
260268
"""
261269
retry_cfg = self._retry if idempotency_key is not None else write_safe_config(self._retry)
262270

@@ -266,6 +274,9 @@ def _call() -> None:
266274
tenant_id=tenant_id,
267275
field_path=field_path,
268276
value=make_string_typed_value(value),
277+
description=description,
278+
value_description=value_description,
279+
expected_checksum=expected_checksum,
269280
),
270281
timeout=self._timeout,
271282
)
@@ -278,17 +289,18 @@ def _call() -> None:
278289
def set_many(
279290
self,
280291
tenant_id: str,
281-
values: dict[str, str],
292+
updates: list[FieldUpdate],
282293
*,
283-
description: str = "",
294+
description: str | None = None,
284295
idempotency_key: str | None = None,
285296
) -> None:
286297
"""Atomically set multiple config values.
287298
288299
Args:
289300
tenant_id: Tenant UUID.
290-
values: Dict mapping field paths to string values.
291-
description: Optional description for the audit log.
301+
updates: List of :class:`FieldUpdate` objects, each carrying a
302+
field path, value, and optional per-field metadata.
303+
description: Optional version-level description for the audit log.
292304
idempotency_key: When provided, the request is retried on
293305
``DEADLINE_EXCEEDED`` in addition to ``UNAVAILABLE``. See
294306
``set()`` for details on retry semantics.
@@ -297,21 +309,24 @@ def set_many(
297309
NotFoundError: If a field does not exist in the schema.
298310
LockedError: If any field is locked.
299311
InvalidArgumentError: If any value fails validation.
312+
ChecksumMismatchError: If any ``expected_checksum`` does not match.
300313
"""
301314
retry_cfg = self._retry if idempotency_key is not None else write_safe_config(self._retry)
302315

303316
def _call() -> None:
304-
updates = [
317+
proto_updates = [
305318
self._pb2.FieldUpdate(
306-
field_path=fp,
307-
value=make_string_typed_value(v),
319+
field_path=u.field_path,
320+
value=make_string_typed_value(u.value),
321+
expected_checksum=u.expected_checksum,
322+
value_description=u.value_description,
308323
)
309-
for fp, v in values.items()
324+
for u in updates
310325
]
311326
self._stub.SetFields(
312327
self._pb2.SetFieldsRequest(
313328
tenant_id=tenant_id,
314-
updates=updates,
329+
updates=proto_updates,
315330
description=description,
316331
),
317332
timeout=self._timeout,

sdk/src/opendecree/types.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,24 @@ class Change:
4444
changed_by: str = ""
4545

4646

47+
@dataclass(frozen=True, slots=True)
48+
class FieldUpdate:
49+
"""A single field update for use with :meth:`ConfigClient.set_many`.
50+
51+
Attributes:
52+
field_path: Dot-separated field path (e.g., ``"payments.fee"``).
53+
value: The value as a string.
54+
expected_checksum: When set, the server rejects the write if the
55+
current value's checksum does not match (optimistic concurrency).
56+
value_description: Optional description stored with this specific value.
57+
"""
58+
59+
field_path: str
60+
value: str
61+
expected_checksum: str | None = None
62+
value_description: str | None = None
63+
64+
4765
@dataclass(frozen=True, slots=True)
4866
class ServerVersion:
4967
"""Server version information from the ServerService.

sdk/tests/test_async_client.py

Lines changed: 35 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99
from opendecree.async_client import AsyncConfigClient
1010
from opendecree.errors import DecreeError, NotFoundError, UnavailableError
11+
from opendecree.types import FieldUpdate
1112
from tests.conftest import FakeRpcError
1213

1314

@@ -135,12 +136,43 @@ async def test_set(self):
135136
await client.set("t1", "payments.fee", "0.5%")
136137
client._stub.SetField.assert_called_once()
137138

139+
@pytest.mark.asyncio
140+
async def test_set_with_metadata(self):
141+
client = self._make_client()
142+
client._stub.SetField = AsyncMock(return_value=MagicMock())
143+
144+
await client.set(
145+
"t1",
146+
"payments.fee",
147+
"0.5%",
148+
description="fee increase",
149+
value_description="Q2 rate",
150+
expected_checksum="abc123",
151+
)
152+
req = client._stub.SetField.call_args[0][0]
153+
assert req.description == "fee increase"
154+
assert req.value_description == "Q2 rate"
155+
assert req.expected_checksum == "abc123"
156+
138157
@pytest.mark.asyncio
139158
async def test_set_many(self):
140159
client = self._make_client()
141160
client._stub.SetFields = AsyncMock(return_value=MagicMock())
142161

143-
await client.set_many("t1", {"a": "1", "b": "2"}, description="batch")
162+
updates = [FieldUpdate("a", "1"), FieldUpdate("b", "2")]
163+
await client.set_many("t1", updates, description="batch")
164+
client._stub.SetFields.assert_called_once()
165+
166+
@pytest.mark.asyncio
167+
async def test_set_many_with_per_field_metadata(self):
168+
client = self._make_client()
169+
client._stub.SetFields = AsyncMock(return_value=MagicMock())
170+
171+
updates = [
172+
FieldUpdate("a", "1", expected_checksum="cs1", value_description="first"),
173+
FieldUpdate("b", "2"),
174+
]
175+
await client.set_many("t1", updates)
144176
client._stub.SetFields.assert_called_once()
145177

146178
@pytest.mark.asyncio
@@ -176,7 +208,7 @@ async def test_set_many_grpc_error(self):
176208
client._stub.SetFields = AsyncMock(side_effect=err)
177209

178210
with pytest.raises(UnavailableError):
179-
await client.set_many("t1", {"a": "1"})
211+
await client.set_many("t1", [FieldUpdate("a", "1")])
180212

181213
@pytest.mark.asyncio
182214
async def test_set_null_grpc_error(self):
@@ -218,7 +250,7 @@ async def test_set_many_does_not_retry_on_deadline_exceeded(self):
218250

219251
with patch("opendecree._retry.asyncio.sleep", new_callable=AsyncMock):
220252
with pytest.raises(DecreeError):
221-
await client.set_many("t1", {"a": "1"})
253+
await client.set_many("t1", [FieldUpdate("a", "1")])
222254

223255
client._stub.SetFields.assert_called_once()
224256

sdk/tests/test_client.py

Lines changed: 33 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77

88
import opendecree
99
from opendecree.errors import DecreeError, NotFoundError, UnavailableError
10+
from opendecree.types import FieldUpdate
1011
from tests.conftest import FakeRpcError
1112

1213

@@ -146,11 +147,40 @@ def test_set(self):
146147
client.set("t1", "payments.fee", "0.5%")
147148
client._stub.SetField.assert_called_once()
148149

150+
def test_set_with_metadata(self):
151+
client = self._make_client()
152+
client._stub.SetField.return_value = MagicMock()
153+
154+
client.set(
155+
"t1",
156+
"payments.fee",
157+
"0.5%",
158+
description="fee increase",
159+
value_description="Q2 rate",
160+
expected_checksum="abc123",
161+
)
162+
req = client._stub.SetField.call_args[0][0]
163+
assert req.description == "fee increase"
164+
assert req.value_description == "Q2 rate"
165+
assert req.expected_checksum == "abc123"
166+
149167
def test_set_many(self):
150168
client = self._make_client()
151169
client._stub.SetFields.return_value = MagicMock()
152170

153-
client.set_many("t1", {"a": "1", "b": "2"}, description="batch")
171+
updates = [FieldUpdate("a", "1"), FieldUpdate("b", "2")]
172+
client.set_many("t1", updates, description="batch")
173+
client._stub.SetFields.assert_called_once()
174+
175+
def test_set_many_with_per_field_metadata(self):
176+
client = self._make_client()
177+
client._stub.SetFields.return_value = MagicMock()
178+
179+
updates = [
180+
FieldUpdate("a", "1", expected_checksum="cs1", value_description="first"),
181+
FieldUpdate("b", "2"),
182+
]
183+
client.set_many("t1", updates)
154184
client._stub.SetFields.assert_called_once()
155185

156186
def test_set_null(self):
@@ -179,7 +209,7 @@ def test_set_many_grpc_error(self):
179209
client._stub.SetFields.side_effect = FakeRpcError(grpc.StatusCode.UNAVAILABLE, "down")
180210

181211
with pytest.raises(UnavailableError):
182-
client.set_many("t1", {"a": "1"})
212+
client.set_many("t1", [FieldUpdate("a", "1")])
183213

184214
def test_set_null_grpc_error(self):
185215
client = self._make_client()
@@ -216,7 +246,7 @@ def test_set_many_does_not_retry_on_deadline_exceeded(self):
216246

217247
with patch("opendecree._retry.time.sleep"):
218248
with pytest.raises(DecreeError):
219-
client.set_many("t1", {"a": "1"})
249+
client.set_many("t1", [FieldUpdate("a", "1")])
220250

221251
client._stub.SetFields.assert_called_once()
222252

0 commit comments

Comments
 (0)