diff --git a/docs/guide/connect.md b/docs/guide/connect.md index d3dbb31..bf0a9af 100644 --- a/docs/guide/connect.md +++ b/docs/guide/connect.md @@ -156,6 +156,39 @@ The `timeout` parameter sets the default per-RPC deadline in seconds (default: 1 client = ConfigClient("localhost:9090", timeout=30.0) ``` +## Custom interceptors + +Pass `interceptors` to inject your own gRPC client interceptors — for logging, metrics, +custom tracing, or anything else that needs to observe or modify outbound RPCs: + +```python +import grpc + +client = ConfigClient( + "localhost:9090", + subject="myapp", + interceptors=[my_logging_interceptor, my_metrics_interceptor], +) +``` + +`ConfigClient` accepts `grpc.UnaryUnaryClientInterceptor` / `grpc.UnaryStreamClientInterceptor` +instances; `AsyncConfigClient` accepts `grpc.aio.ClientInterceptor` instances (passed +directly to the `grpc.aio` channel). + +On `ConfigClient`, interceptor order (outermost first) is: + +1. **OTel** (if `otel=True`) +2. **Your `interceptors`**, in list order +3. **The SDK's internal auth interceptor** (innermost — attaches `subject`/`role`/ + `tenant_id`/token metadata last, closest to the wire) + +This means your interceptors see the call before auth metadata is attached, and can inspect +or wrap the call as it travels outward through any later layers (e.g. OTel spans). + +On `AsyncConfigClient`, `grpc.aio` doesn't support channel-level interceptor stacking the +same way — auth metadata is attached directly per call rather than via an interceptor. +There, order is simply **OTel first, then your `interceptors`**. + ## OpenTelemetry Pass `otel=True` to trace all RPCs with OpenTelemetry: @@ -170,8 +203,56 @@ Requires the optional extra: pip install 'opendecree[otel]' ``` -The OTel interceptor is outermost and wraps all other interceptors, so every outbound RPC -appears as a span in your traces. +The OTel interceptor is outermost and wraps all other interceptors (including any you pass +via `interceptors`), so every outbound RPC appears as a span in your traces. + +## Version compatibility + +The SDK can check that the server it's talking to is within its supported version range +(`opendecree.SUPPORTED_SERVER_VERSION`, e.g. `">=0.3.0,<1.0.0"`). + +### Automatic check on first call + +Pass `check_version=True` to the constructor to run the check lazily before the first RPC: + +```python +client = ConfigClient("localhost:9090", subject="myapp", check_version=True) + +# Raises IncompatibleServerError here if the server is outside the supported range. +client.get("tenant-id", "payments.fee") +``` + +The check runs once per client instance and is cached. + +### Manual checks + +Call `get_server_version()` to fetch (and cache) the server's version and commit: + +```python +info = client.get_server_version() +print(info.version) # e.g. "0.3.1" +print(info.commit) # server build's git commit hash +``` + +Call `check_compatibility()` to explicitly raise if the cached (or freshly fetched) server +version is outside the supported range: + +```python +from opendecree import IncompatibleServerError + +try: + client.check_compatibility() +except IncompatibleServerError as e: + print(f"server is incompatible with this SDK: {e}") +``` + +`IncompatibleServerError` inherits from `DecreeError` — see +[Error handling](#error-handling) below. `AsyncConfigClient` exposes the same +`get_server_version()` and `check_compatibility()` methods — `await` them there. + +!!! note "Unparseable versions skip the check" + If the server reports a version string that isn't valid PEP 440 (e.g. a `"dev"` build), + the compatibility check is skipped rather than raising. ## Error handling diff --git a/docs/guide/types.md b/docs/guide/types.md new file mode 100644 index 0000000..03cff87 --- /dev/null +++ b/docs/guide/types.md @@ -0,0 +1,151 @@ +# Types and Values + +How OpenDecree's schema field types map to Python types, and how to read and write +typed values — including atomic multi-field writes with `set_many`. + +## Go-to-Python type mapping + +The server stores every config value as a string and validates it against a +schema-defined field type. The SDK converts between that wire representation and +native Python types at the boundary — both when reading with a typed `get()` and +(for `watch()`) when registering a field. + +| Schema field type | Python type | Notes | +|-------------------|-------------|-------| +| `integer` | `int` | Decimal string, e.g. `"42"`, `"-1"` | +| `number` | `float` | Decimal string, e.g. `"3.14"`, `"-99.9"` | +| `string` | `str` | Free-form text | +| `bool` | `bool` | `"true"`/`"1"` → `True`, `"false"`/`"0"` → `False` | +| `time` | `datetime` | RFC 3339 string, parsed with `datetime.fromisoformat` | +| `duration` | `timedelta` | Go-style duration string, e.g. `"24h"`, `"30m"`, `"500ms"`, `"1h30m"` | +| `url` | `str` (`opendecree.URL`) | `URL` is a type alias for `str` — semantically distinct, converted identically | +| `json` | `dict` / `list` | JSON-decoded; the result must match the requested container type | + +`opendecree.URL` exists so you can express intent in your own type annotations +(e.g. `watcher.field("webhooks.endpoint", URL)`) — at runtime it behaves exactly +like `str`. + +## Supported `get()` types + +Pass the target type as the third positional argument to `get()` to receive a +converted value instead of the raw string: + +```python +from datetime import datetime, timedelta + +from opendecree import URL, ConfigClient + +with ConfigClient("localhost:9090", subject="myapp") as client: + name: str = client.get("tenant-id", "service.name") + retries: int = client.get("tenant-id", "payments.retries", int) + fee: float = client.get("tenant-id", "payments.fee", float) + enabled: bool = client.get("tenant-id", "payments.enabled", bool) + deploy_at: datetime = client.get("tenant-id", "release.deploy_at", datetime) + timeout: timedelta = client.get("tenant-id", "payments.timeout", timedelta) + webhook: URL = client.get("tenant-id", "webhooks.endpoint", URL) + metadata: dict = client.get("tenant-id", "service.metadata", dict) + tags: list = client.get("tenant-id", "service.tags", list) +``` + +Supported types: `str` (default), `int`, `float`, `bool`, `datetime`, `timedelta`, +`dict`, `list`. `URL` is an alias for `str`. + +For `dict`/`list`, the SDK JSON-decodes the raw value and checks that the decoded +result is an instance of the requested container type — decoding `{"a": 1}` as +`list` (or `[1, 2]` as `dict`) raises `TypeMismatchError`. + +```python +try: + metadata = client.get("tenant-id", "service.metadata", dict) +except TypeMismatchError: + print("value is not a JSON object") +``` + +`AsyncConfigClient.get()` supports the same set of types — `await` the call instead. + +### Nullable reads + +Pass `nullable=True` to get `None` back for unset/null fields instead of raising +`NotFoundError`: + +```python +description = client.get("tenant-id", "service.description", str, nullable=True) +if description is None: + print("not set") +``` + +## Writing values + +`set`, `set_many`, and `set_null` all send the `value` argument as a **string** +on the wire — there's no separate typed-write API. The server validates the +written value against the field's declared schema type. + +!!! warning "Currently string-typed fields only" + The SDK always sends writes as a string-valued `TypedValue`. The server + requires the value's wire representation to match the field's declared type + exactly (no coercion), so `set`/`set_many`/`set_null` only work against + `string`-typed fields today — writing to a `bool`, `integer`, `number`, + `time`, `duration`, `url`, or `json` field raises `InvalidArgumentError` + (e.g. `"expected bool value"`). This is a known SDK limitation, not + something to work around in your own code. + +```python +client.set("tenant-id", "service.name", "checkout-api") +client.set("tenant-id", "payments.currency", "EUR") +``` + +## Bulk writes with `set_many` and `FieldUpdate` + +`set_many` atomically applies a batch of field updates in a single version — either +all of them succeed, or none do. Each update is a `FieldUpdate`: + +```python +@dataclass(frozen=True, slots=True) +class FieldUpdate: + field_path: str + value: str + expected_checksum: str | None = None + value_description: str | None = None +``` + +| Field | Type | Meaning | +|-------|------|---------| +| `field_path` | `str` | Dot-separated field path, e.g. `"payments.fee"` | +| `value` | `str` | The value as a string (same wire format as `set`) | +| `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 | +| `value_description` | `str \| None` | Optional description stored with this specific value | + +```python +from opendecree import ConfigClient, FieldUpdate + +with ConfigClient("localhost:9090", subject="myapp") as client: + client.set_many( + "tenant-id", + [ + FieldUpdate("service.name", "checkout-api"), + FieldUpdate("payments.currency", "EUR"), + FieldUpdate( + "service.region", + "us-east-1", + expected_checksum="abc123", + value_description="moved for latency", + ), + ], + description="tune service settings", + ) +``` + +Like `set`, `set_many` is currently limited to `string`-typed fields — see the +warning above. + +`set_many` accepts the same `description` and `idempotency_key` keyword arguments +as `set` (see [Connecting → Retry](connect.md#retry) for retry/idempotency +semantics — bulk writes are subject to the same write-safety rules). + +`AsyncConfigClient.set_many` works identically with `await`. + +## `watch()` field types + +`ConfigWatcher.field()` and `AsyncConfigWatcher.field()` support a smaller set of +types than `get()` — see [Watching → Supported field types](watch.md#supported-field-types) +for the list and suggested defaults. diff --git a/mkdocs.yml b/mkdocs.yml index ed332b4..a251c75 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -45,6 +45,7 @@ nav: - Home: index.md - Guide: - Connecting: guide/connect.md + - Types and Values: guide/types.md - Watching: guide/watch.md - Async Usage: guide/async.md - API Reference: api/index.md