Skip to content

Commit 964f08e

Browse files
authored
Document type mapping, FieldUpdate, interceptors, and version compatibility (#141)
Add a new Types and Values guide page covering: - The full Go-schema-type to Python-type mapping (including time, duration, url, json -> datetime, timedelta, URL, dict/list), which docs previously only listed five scalar types for. - The FieldUpdate dataclass shape and a set_many bulk-write example. - A note on the SDK's current string-only write limitation: set/set_many always send string-valued TypedValues, and the server requires an exact oneof-variant match with no coercion, so writes to non-string-typed fields raise InvalidArgumentError today. Extend the Connecting guide with the previously-undocumented `interceptors` constructor kwarg (including interceptor ordering for sync vs async clients) and the version-compatibility surface (check_version, get_server_version, check_compatibility, IncompatibleServerError). Closes #136
1 parent 0b12ee5 commit 964f08e

3 files changed

Lines changed: 235 additions & 2 deletions

File tree

docs/guide/connect.md

Lines changed: 83 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,39 @@ The `timeout` parameter sets the default per-RPC deadline in seconds (default: 1
156156
client = ConfigClient("localhost:9090", timeout=30.0)
157157
```
158158

159+
## Custom interceptors
160+
161+
Pass `interceptors` to inject your own gRPC client interceptors — for logging, metrics,
162+
custom tracing, or anything else that needs to observe or modify outbound RPCs:
163+
164+
```python
165+
import grpc
166+
167+
client = ConfigClient(
168+
"localhost:9090",
169+
subject="myapp",
170+
interceptors=[my_logging_interceptor, my_metrics_interceptor],
171+
)
172+
```
173+
174+
`ConfigClient` accepts `grpc.UnaryUnaryClientInterceptor` / `grpc.UnaryStreamClientInterceptor`
175+
instances; `AsyncConfigClient` accepts `grpc.aio.ClientInterceptor` instances (passed
176+
directly to the `grpc.aio` channel).
177+
178+
On `ConfigClient`, interceptor order (outermost first) is:
179+
180+
1. **OTel** (if `otel=True`)
181+
2. **Your `interceptors`**, in list order
182+
3. **The SDK's internal auth interceptor** (innermost — attaches `subject`/`role`/
183+
`tenant_id`/token metadata last, closest to the wire)
184+
185+
This means your interceptors see the call before auth metadata is attached, and can inspect
186+
or wrap the call as it travels outward through any later layers (e.g. OTel spans).
187+
188+
On `AsyncConfigClient`, `grpc.aio` doesn't support channel-level interceptor stacking the
189+
same way — auth metadata is attached directly per call rather than via an interceptor.
190+
There, order is simply **OTel first, then your `interceptors`**.
191+
159192
## OpenTelemetry
160193

161194
Pass `otel=True` to trace all RPCs with OpenTelemetry:
@@ -170,8 +203,56 @@ Requires the optional extra:
170203
pip install 'opendecree[otel]'
171204
```
172205

173-
The OTel interceptor is outermost and wraps all other interceptors, so every outbound RPC
174-
appears as a span in your traces.
206+
The OTel interceptor is outermost and wraps all other interceptors (including any you pass
207+
via `interceptors`), so every outbound RPC appears as a span in your traces.
208+
209+
## Version compatibility
210+
211+
The SDK can check that the server it's talking to is within its supported version range
212+
(`opendecree.SUPPORTED_SERVER_VERSION`, e.g. `">=0.3.0,<1.0.0"`).
213+
214+
### Automatic check on first call
215+
216+
Pass `check_version=True` to the constructor to run the check lazily before the first RPC:
217+
218+
```python
219+
client = ConfigClient("localhost:9090", subject="myapp", check_version=True)
220+
221+
# Raises IncompatibleServerError here if the server is outside the supported range.
222+
client.get("tenant-id", "payments.fee")
223+
```
224+
225+
The check runs once per client instance and is cached.
226+
227+
### Manual checks
228+
229+
Call `get_server_version()` to fetch (and cache) the server's version and commit:
230+
231+
```python
232+
info = client.get_server_version()
233+
print(info.version) # e.g. "0.3.1"
234+
print(info.commit) # server build's git commit hash
235+
```
236+
237+
Call `check_compatibility()` to explicitly raise if the cached (or freshly fetched) server
238+
version is outside the supported range:
239+
240+
```python
241+
from opendecree import IncompatibleServerError
242+
243+
try:
244+
client.check_compatibility()
245+
except IncompatibleServerError as e:
246+
print(f"server is incompatible with this SDK: {e}")
247+
```
248+
249+
`IncompatibleServerError` inherits from `DecreeError` — see
250+
[Error handling](#error-handling) below. `AsyncConfigClient` exposes the same
251+
`get_server_version()` and `check_compatibility()` methods — `await` them there.
252+
253+
!!! note "Unparseable versions skip the check"
254+
If the server reports a version string that isn't valid PEP 440 (e.g. a `"dev"` build),
255+
the compatibility check is skipped rather than raising.
175256

176257
## Error handling
177258

docs/guide/types.md

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
# Types and Values
2+
3+
How OpenDecree's schema field types map to Python types, and how to read and write
4+
typed values — including atomic multi-field writes with `set_many`.
5+
6+
## Go-to-Python type mapping
7+
8+
The server stores every config value as a string and validates it against a
9+
schema-defined field type. The SDK converts between that wire representation and
10+
native Python types at the boundary — both when reading with a typed `get()` and
11+
(for `watch()`) when registering a field.
12+
13+
| Schema field type | Python type | Notes |
14+
|-------------------|-------------|-------|
15+
| `integer` | `int` | Decimal string, e.g. `"42"`, `"-1"` |
16+
| `number` | `float` | Decimal string, e.g. `"3.14"`, `"-99.9"` |
17+
| `string` | `str` | Free-form text |
18+
| `bool` | `bool` | `"true"`/`"1"``True`, `"false"`/`"0"``False` |
19+
| `time` | `datetime` | RFC 3339 string, parsed with `datetime.fromisoformat` |
20+
| `duration` | `timedelta` | Go-style duration string, e.g. `"24h"`, `"30m"`, `"500ms"`, `"1h30m"` |
21+
| `url` | `str` (`opendecree.URL`) | `URL` is a type alias for `str` — semantically distinct, converted identically |
22+
| `json` | `dict` / `list` | JSON-decoded; the result must match the requested container type |
23+
24+
`opendecree.URL` exists so you can express intent in your own type annotations
25+
(e.g. `watcher.field("webhooks.endpoint", URL)`) — at runtime it behaves exactly
26+
like `str`.
27+
28+
## Supported `get()` types
29+
30+
Pass the target type as the third positional argument to `get()` to receive a
31+
converted value instead of the raw string:
32+
33+
```python
34+
from datetime import datetime, timedelta
35+
36+
from opendecree import URL, ConfigClient
37+
38+
with ConfigClient("localhost:9090", subject="myapp") as client:
39+
name: str = client.get("tenant-id", "service.name")
40+
retries: int = client.get("tenant-id", "payments.retries", int)
41+
fee: float = client.get("tenant-id", "payments.fee", float)
42+
enabled: bool = client.get("tenant-id", "payments.enabled", bool)
43+
deploy_at: datetime = client.get("tenant-id", "release.deploy_at", datetime)
44+
timeout: timedelta = client.get("tenant-id", "payments.timeout", timedelta)
45+
webhook: URL = client.get("tenant-id", "webhooks.endpoint", URL)
46+
metadata: dict = client.get("tenant-id", "service.metadata", dict)
47+
tags: list = client.get("tenant-id", "service.tags", list)
48+
```
49+
50+
Supported types: `str` (default), `int`, `float`, `bool`, `datetime`, `timedelta`,
51+
`dict`, `list`. `URL` is an alias for `str`.
52+
53+
For `dict`/`list`, the SDK JSON-decodes the raw value and checks that the decoded
54+
result is an instance of the requested container type — decoding `{"a": 1}` as
55+
`list` (or `[1, 2]` as `dict`) raises `TypeMismatchError`.
56+
57+
```python
58+
try:
59+
metadata = client.get("tenant-id", "service.metadata", dict)
60+
except TypeMismatchError:
61+
print("value is not a JSON object")
62+
```
63+
64+
`AsyncConfigClient.get()` supports the same set of types — `await` the call instead.
65+
66+
### Nullable reads
67+
68+
Pass `nullable=True` to get `None` back for unset/null fields instead of raising
69+
`NotFoundError`:
70+
71+
```python
72+
description = client.get("tenant-id", "service.description", str, nullable=True)
73+
if description is None:
74+
print("not set")
75+
```
76+
77+
## Writing values
78+
79+
`set`, `set_many`, and `set_null` all send the `value` argument as a **string**
80+
on the wire — there's no separate typed-write API. The server validates the
81+
written value against the field's declared schema type.
82+
83+
!!! warning "Currently string-typed fields only"
84+
The SDK always sends writes as a string-valued `TypedValue`. The server
85+
requires the value's wire representation to match the field's declared type
86+
exactly (no coercion), so `set`/`set_many`/`set_null` only work against
87+
`string`-typed fields today — writing to a `bool`, `integer`, `number`,
88+
`time`, `duration`, `url`, or `json` field raises `InvalidArgumentError`
89+
(e.g. `"expected bool value"`). This is a known SDK limitation, not
90+
something to work around in your own code.
91+
92+
```python
93+
client.set("tenant-id", "service.name", "checkout-api")
94+
client.set("tenant-id", "payments.currency", "EUR")
95+
```
96+
97+
## Bulk writes with `set_many` and `FieldUpdate`
98+
99+
`set_many` atomically applies a batch of field updates in a single version — either
100+
all of them succeed, or none do. Each update is a `FieldUpdate`:
101+
102+
```python
103+
@dataclass(frozen=True, slots=True)
104+
class FieldUpdate:
105+
field_path: str
106+
value: str
107+
expected_checksum: str | None = None
108+
value_description: str | None = None
109+
```
110+
111+
| Field | Type | Meaning |
112+
|-------|------|---------|
113+
| `field_path` | `str` | Dot-separated field path, e.g. `"payments.fee"` |
114+
| `value` | `str` | The value as a string (same wire format as `set`) |
115+
| `expected_checksum` | `str \| None` | Optional per-field optimistic-concurrency check — the whole batch is rejected with `ChecksumMismatchError` if any field's current checksum doesn't match |
116+
| `value_description` | `str \| None` | Optional description stored with this specific value |
117+
118+
```python
119+
from opendecree import ConfigClient, FieldUpdate
120+
121+
with ConfigClient("localhost:9090", subject="myapp") as client:
122+
client.set_many(
123+
"tenant-id",
124+
[
125+
FieldUpdate("service.name", "checkout-api"),
126+
FieldUpdate("payments.currency", "EUR"),
127+
FieldUpdate(
128+
"service.region",
129+
"us-east-1",
130+
expected_checksum="abc123",
131+
value_description="moved for latency",
132+
),
133+
],
134+
description="tune service settings",
135+
)
136+
```
137+
138+
Like `set`, `set_many` is currently limited to `string`-typed fields — see the
139+
warning above.
140+
141+
`set_many` accepts the same `description` and `idempotency_key` keyword arguments
142+
as `set` (see [Connecting → Retry](connect.md#retry) for retry/idempotency
143+
semantics — bulk writes are subject to the same write-safety rules).
144+
145+
`AsyncConfigClient.set_many` works identically with `await`.
146+
147+
## `watch()` field types
148+
149+
`ConfigWatcher.field()` and `AsyncConfigWatcher.field()` support a smaller set of
150+
types than `get()` — see [Watching → Supported field types](watch.md#supported-field-types)
151+
for the list and suggested defaults.

mkdocs.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ nav:
4545
- Home: index.md
4646
- Guide:
4747
- Connecting: guide/connect.md
48+
- Types and Values: guide/types.md
4849
- Watching: guide/watch.md
4950
- Async Usage: guide/async.md
5051
- API Reference: api/index.md

0 commit comments

Comments
 (0)