diff --git a/.gitignore b/.gitignore index 7bb8506..0f8e2cf 100644 --- a/.gitignore +++ b/.gitignore @@ -34,7 +34,7 @@ htmlcov/ .ruff_cache/ # Generated docs -sdk/docs/api/ +site/ # OS .DS_Store diff --git a/CLAUDE.md b/CLAUDE.md index 840b904..d261422 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -16,7 +16,7 @@ clients, a field-watching subscription layer, and optional OpenTelemetry instrum | Lint / format | ruff | | Type checking | mypy | | Tests | pytest, pytest-asyncio | -| Docs | pdoc | +| Docs | mkdocs (material) + mkdocstrings | ## Development diff --git a/Makefile b/Makefile index ce49ba2..9b97261 100644 --- a/Makefile +++ b/Makefile @@ -64,11 +64,11 @@ integration: $(TOOLS_SENTINEL) @test -n "$(DECREE_TEST_ADDR)" || (echo "Set DECREE_TEST_ADDR=host:port" && exit 1) $(DOCKER_RUN_ROOT) sh -c "cd sdk && pip install -e . -q 2>/dev/null && DECREE_TEST_ADDR=$(DECREE_TEST_ADDR) pytest -m integration -v" -## docs: Generate API reference HTML from docstrings (pdoc) -docs: $(TOOLS_SENTINEL) - @mkdir -p sdk/docs/api - $(DOCKER_RUN_ROOT) sh -c "cd sdk && pip install -e . -q 2>/dev/null && pdoc --output-directory /workspace/sdk/docs/api --no-show-source --docformat google opendecree !opendecree._generated && chown -R $(shell id -u):$(shell id -g) /workspace/sdk/docs/api" - @echo "Generated API docs in sdk/docs/api/" +## docs: Build the mkdocs documentation site (output in site/) +docs: + pip install -q 'mkdocs-material' 'mkdocstrings[python]' + mkdocs build + @echo "Built docs site in site/" ## build: Build sdist + wheel build: $(TOOLS_SENTINEL) diff --git a/README.md b/README.md index 89550a3..a2f7229 100644 --- a/README.md +++ b/README.md @@ -54,7 +54,7 @@ with ConfigClient("localhost:9090", subject="myapp") as client: ``` > **Fork safety:** gRPC channels are not fork-safe. Create `ConfigClient` (and start any watcher) -> *after* forking — not before. See [Fork safety](sdk/docs/watching.md#fork-safety) for details. +> *after* forking — not before. See [Fork safety](https://opendecree.github.io/decree-python/guide/watch/#fork-safety) for details. ## Async @@ -80,10 +80,13 @@ Runnable examples in the [`examples/`](examples/) directory: ## Documentation -- [Quick Start](sdk/docs/quickstart.md) -- [Configuration](sdk/docs/configuration.md) -- [Watching](sdk/docs/watching.md) -- [Async Usage](sdk/docs/async.md) +Full documentation, including guides and the API reference, is published at +**[opendecree.github.io/decree-python](https://opendecree.github.io/decree-python)**: + +- [Connecting](https://opendecree.github.io/decree-python/guide/connect/) — client options (auth, TLS, retry, timeouts, error handling) +- [Watching](https://opendecree.github.io/decree-python/guide/watch/) — live subscriptions and change patterns +- [Async Usage](https://opendecree.github.io/decree-python/guide/async/) — async client and watcher +- [API Reference](https://opendecree.github.io/decree-python/api/) — full auto-generated API docs For detailed concepts (schemas, typed values, versioning, auth), see the [main OpenDecree docs](https://github.com/opendecree/decree). diff --git a/build/Dockerfile.tools b/build/Dockerfile.tools index 0f298fb..7d93c4a 100644 --- a/build/Dockerfile.tools +++ b/build/Dockerfile.tools @@ -11,7 +11,6 @@ RUN pip install --no-cache-dir \ grpcio>=1.68.0 \ protobuf>=5.29.0 \ googleapis-common-protos>=1.66.0 \ - build \ - pdoc>=15.0 + build WORKDIR /workspace diff --git a/sdk/docs/async.md b/docs/guide/async.md similarity index 70% rename from sdk/docs/async.md rename to docs/guide/async.md index 8c061ca..ef9e06b 100644 --- a/sdk/docs/async.md +++ b/docs/guide/async.md @@ -5,7 +5,7 @@ The SDK provides async equivalents for all sync APIs, built on `grpc.aio`. ## AsyncConfigClient ```python -from opendecree import AsyncConfigClient +from opendecree import AsyncConfigClient, FieldUpdate async with AsyncConfigClient("localhost:9090", subject="myapp") as client: # Typed gets (same overload pattern as sync) @@ -18,11 +18,15 @@ async with AsyncConfigClient("localhost:9090", subject="myapp") as client: # Writes await client.set("tenant-id", "payments.fee", "0.5%") - await client.set_many("tenant-id", {"a": "1", "b": "2"}) + await client.set_many( + "tenant-id", + [FieldUpdate("a", "1"), FieldUpdate("b", "2")], + description="batch update", + ) await client.set_null("tenant-id", "payments.fee") ``` -Same constructor options as `ConfigClient` — see [Configuration](configuration.md). +Same constructor options as `ConfigClient` — see [Connecting](connect.md). ## AsyncConfigWatcher @@ -30,11 +34,10 @@ Same constructor options as `ConfigClient` — see [Configuration](configuration from opendecree import AsyncConfigClient async with AsyncConfigClient("localhost:9090", subject="myapp") as client: - watcher = client.watch("tenant-id") - fee = watcher.field("payments.fee", float, default=0.01) - enabled = watcher.field("payments.enabled", bool, default=False) + async with client.watch("tenant-id") as watcher: + fee = watcher.field("payments.fee", float, default=0.01) + enabled = watcher.field("payments.enabled", bool, default=False) - async with watcher: # .value works the same print(fee.value) @@ -48,12 +51,12 @@ async with AsyncConfigClient("localhost:9090", subject="myapp") as client: Use `async for` instead of `for`: ```python -watcher = client.watch("tenant-id") -fee = watcher.field("payments.fee", float, default=0.01) +async with AsyncConfigClient("localhost:9090", subject="myapp") as client: + async with client.watch("tenant-id") as watcher: + fee = watcher.field("payments.fee", float, default=0.01) -async with watcher: - async for change in fee.changes(): - print(f"{change.old_value} -> {change.new_value}") + async for change in fee.changes(): + print(f"{change.old_value} -> {change.new_value}") ``` ### Callbacks @@ -83,11 +86,18 @@ The public API is otherwise identical — same constructor options, same `get()` ## When to use async Use the async API when: + - Your application already uses asyncio (FastAPI, aiohttp, etc.) - You need to manage many concurrent connections efficiently Use the sync API when: + - Your application is synchronous (Flask, Django, scripts) - Simplicity matters more than concurrency Both APIs are equally capable and tested. + +## Next steps + +- [Connecting](connect.md) — client options (auth, TLS, retry, error handling) +- [Watching](watch.md) — live subscriptions and change patterns diff --git a/docs/guide/watch.md b/docs/guide/watch.md index 2a67013..f258fa1 100644 --- a/docs/guide/watch.md +++ b/docs/guide/watch.md @@ -130,6 +130,8 @@ async with AsyncConfigClient("localhost:9090", subject="myapp") as client: Callbacks work the same as the sync watcher — they are plain functions, not coroutines. +See [Async Usage](async.md) for the full async client and watcher API. + ## Fork safety gRPC channels are **not fork-safe**. Do not create a `ConfigClient` before calling `os.fork()`. diff --git a/docs/index.md b/docs/index.md index 1429972..75561eb 100644 --- a/docs/index.md +++ b/docs/index.md @@ -82,6 +82,7 @@ Runnable examples are available in the [`examples/`](https://github.com/opendecr - [Connecting](guide/connect.md) — all client options (auth, TLS, retry, timeouts) - [Watching](guide/watch.md) — live subscriptions and change patterns +- [Async Usage](guide/async.md) — async client and watcher - [API Reference](api/index.md) — full auto-generated API docs For server concepts (schemas, typed values, versioning, auth), see the [main OpenDecree docs](https://github.com/opendecree/decree). diff --git a/mkdocs.yml b/mkdocs.yml index fb0acf2..ed332b4 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -46,6 +46,7 @@ nav: - Guide: - Connecting: guide/connect.md - Watching: guide/watch.md + - Async Usage: guide/async.md - API Reference: api/index.md markdown_extensions: diff --git a/sdk/README.md b/sdk/README.md index 7465bef..c6e4937 100644 --- a/sdk/README.md +++ b/sdk/README.md @@ -61,10 +61,13 @@ async with AsyncConfigClient("localhost:9090", subject="myapp") as client: ## Documentation -- [Quick Start](docs/quickstart.md) -- [Configuration](docs/configuration.md) -- [Watching](docs/watching.md) -- [Async Usage](docs/async.md) +Full documentation, including guides and the API reference, is published at +**[opendecree.github.io/decree-python](https://opendecree.github.io/decree-python)**: + +- [Connecting](https://opendecree.github.io/decree-python/guide/connect/) — client options (auth, TLS, retry, timeouts, error handling) +- [Watching](https://opendecree.github.io/decree-python/guide/watch/) — live subscriptions and change patterns +- [Async Usage](https://opendecree.github.io/decree-python/guide/async/) — async client and watcher +- [API Reference](https://opendecree.github.io/decree-python/api/) — full auto-generated API docs For detailed concepts (schemas, typed values, versioning, auth), see the [main OpenDecree docs](https://github.com/opendecree/decree). diff --git a/sdk/docs/configuration.md b/sdk/docs/configuration.md deleted file mode 100644 index 3623149..0000000 --- a/sdk/docs/configuration.md +++ /dev/null @@ -1,223 +0,0 @@ -# Configuration - -All client options for `ConfigClient` and `AsyncConfigClient`. - -## Constructor - -```python -ConfigClient( - target="localhost:9090", # gRPC server address (host:port) - *, - # Auth (metadata headers) - subject: str | None = None, # x-subject header - role: str = "superadmin", # x-role header - tenant_id: str | None = None,# x-tenant-id header - token: str | None = None, # Bearer token (alternative to metadata) - - # Connection - insecure: bool = True, # plaintext (no TLS) — default for dev - credentials: grpc.ChannelCredentials | None = None, # TLS credentials - - # Behavior - timeout: float = 10.0, # default RPC timeout in seconds - retry: RetryConfig | None = RetryConfig(), # retry config (None to disable) - - # Observability - otel: bool = False, # wire OTel gRPC interceptor (requires opendecree[otel]) -) -``` - -`AsyncConfigClient` accepts the same options. - -## Authentication - -OpenDecree supports two auth modes: - -### Metadata headers (default) - -The server reads identity from gRPC metadata headers. No JWT or tokens needed. - -```python -client = ConfigClient( - "localhost:9090", - subject="myapp", # who is making the request - role="superadmin", # role (default: superadmin) - tenant_id="tenant-123", # optional: default tenant for all calls -) -``` - -Non-superadmin roles require a `tenant_id`. For users with access to multiple tenants, -pass a comma-separated list: - -```python -client = ConfigClient( - "localhost:9090", - subject="alice", - role="admin", - tenant_id="tenant-123,tenant-456", # access to multiple tenants -) -``` - -Each API call specifies which tenant to operate on via the `tenant_id` parameter. -The server validates that the requested tenant is in the caller's allowed list. - -### Bearer token - -For JWT-enabled servers, pass a token instead: - -```python -client = ConfigClient( - "localhost:9090", - token="eyJhbGciOiJS...", -) -``` - -The JWT `tenant_ids` claim (array) determines which tenants the caller can access. -When a `token` is provided, metadata headers (`subject`, `role`, `tenant_id`) are ignored. - -## TLS - -By default, clients connect without TLS (`insecure=True`). For production: - -```python -import grpc - -creds = grpc.ssl_channel_credentials( - root_certificates=open("ca.pem", "rb").read(), -) - -client = ConfigClient( - "decree.example.com:443", - insecure=False, - credentials=creds, - subject="myapp", -) -``` - -## Retry - -Transient gRPC errors are retried automatically with exponential backoff and jitter. - -```python -from opendecree import ConfigClient, RetryConfig - -# Custom retry settings -client = ConfigClient( - "localhost:9090", - retry=RetryConfig( - max_attempts=5, - initial_backoff=0.2, # seconds - max_backoff=10.0, # seconds - multiplier=2.0, - retryable_codes=( - grpc.StatusCode.UNAVAILABLE, - grpc.StatusCode.DEADLINE_EXCEEDED, - ), - ), -) - -# Disable retry -client = ConfigClient("localhost:9090", retry=None) -``` - -Default: 3 attempts, 0.1s initial backoff, 5s max, 2x multiplier. - -### Read vs. write retry semantics - -**Reads** (`get`, `get_all`) retry on both `UNAVAILABLE` and `DEADLINE_EXCEEDED` — reads are -idempotent so a duplicate is always safe. - -**Writes** (`set`, `set_many`, `set_null`) retry only on `UNAVAILABLE` by default. -`DEADLINE_EXCEEDED` is excluded because the server may have already applied the write before the -client timed out — retrying without coordination would double-apply the change (duplicate audit -log entry, version bump). - -To opt a write into `DEADLINE_EXCEEDED` retry, pass an `idempotency_key`: - -```python -import uuid - -# Only use this when a duplicate apply is harmless for your use case. -client.set( - "tenant-id", - "feature_flags.dark_mode", - "true", - idempotency_key=str(uuid.uuid4()), # unique per request -) -``` - -Use `idempotency_key` only when the write is genuinely safe to apply more than once — for example, -setting a field to a known constant value. - -## OpenTelemetry instrumentation - -Pass `otel=True` to wire an OpenTelemetry gRPC interceptor. Get, set, and watch RPCs will appear as spans in your application traces. - -```python -from opendecree import ConfigClient - -client = ConfigClient("localhost:9090", otel=True) -``` - -Requires the optional extra: - -``` -pip install 'opendecree[otel]' -``` - -The OTel interceptor is outermost — it wraps both user-supplied interceptors and the SDK's internal auth interceptor, so every outbound RPC is traced end-to-end. The same flag works on `AsyncConfigClient`: - -```python -from opendecree import AsyncConfigClient - -async with AsyncConfigClient("localhost:9090", otel=True) as client: - val = await client.get("tenant-id", "payments.fee") -``` - -The watcher inherits the client's already-instrumented channel — no additional configuration needed. - -## Timeouts - -The `timeout` parameter sets the default per-RPC deadline in seconds: - -```python -client = ConfigClient("localhost:9090", timeout=30.0) -``` - -Default: 10 seconds. - -## Error types - -All exceptions inherit from `DecreeError`: - -| Exception | gRPC Code | When | -|-----------|-----------|------| -| `NotFoundError` | NOT_FOUND | Field or tenant doesn't exist | -| `AlreadyExistsError` | ALREADY_EXISTS | Duplicate create | -| `InvalidArgumentError` | INVALID_ARGUMENT | Bad request data | -| `LockedError` | FAILED_PRECONDITION | Field is locked | -| `ChecksumMismatchError` | ABORTED | Optimistic concurrency conflict | -| `PermissionDeniedError` | PERMISSION_DENIED / UNAUTHENTICATED | Auth failure | -| `UnavailableError` | UNAVAILABLE | Server unreachable | -| `TypeMismatchError` | — | Wrong type passed to typed getter | -| `IncompatibleServerError` | — | Server version mismatch | - -## Return types - -`get_all()` returns a plain `dict[str, str]` mapping field paths to their string -values — not a dataclass. The other typed return values are frozen dataclasses: - -```python -@dataclass(frozen=True, slots=True) -class Change: - field_path: str - old_value: str | None - new_value: str | None - version: int - changed_by: str - -@dataclass(frozen=True, slots=True) -class ServerVersion: - version: str - commit: str -``` diff --git a/sdk/docs/quickstart.md b/sdk/docs/quickstart.md deleted file mode 100644 index 1a3e1bd..0000000 --- a/sdk/docs/quickstart.md +++ /dev/null @@ -1,110 +0,0 @@ -# Quick Start - -Get up and running with the OpenDecree Python SDK in under 5 minutes. - -## Install - -```bash -pip install opendecree -``` - -## Prerequisites - -You need a running OpenDecree server (v0.3.0+). For local development: - -```bash -# In the decree repo -docker compose up -d -``` - -The server defaults to `localhost:9090` with no authentication required. - -## Read a config value - -```python -from opendecree import ConfigClient - -with ConfigClient("localhost:9090", subject="myapp") as client: - fee = client.get("tenant-id", "payments.fee") - print(fee) # "0.5%" -``` - -The `with` block manages the gRPC connection — it opens on enter and closes on exit. - -## Typed reads - -Pass a type argument to `get()` for automatic conversion: - -```python -with ConfigClient("localhost:9090", subject="myapp") as client: - retries = client.get("tenant-id", "payments.retries", int) # → int - enabled = client.get("tenant-id", "payments.enabled", bool) # → bool - rate = client.get("tenant-id", "payments.fee_rate", float) # → float -``` - -Supported types: `str` (default), `int`, `float`, `bool`, `timedelta`. - -## Write a value - -```python -with ConfigClient("localhost:9090", subject="myapp") as client: - client.set("tenant-id", "payments.fee", "0.5%") - - # Bulk writes - client.set_many("tenant-id", {"a": "1", "b": "2"}, description="batch update") - - # Set to null - client.set_null("tenant-id", "payments.fee") -``` - -## Watch for changes - -```python -with ConfigClient("localhost:9090", subject="myapp") as client: - watcher = client.watch("tenant-id") - fee = watcher.field("payments.fee", float, default=0.01) - - @fee.on_change - def on_fee_change(old: float, new: float): - print(f"Fee changed: {old} -> {new}") - - with watcher: - print(fee.value) # current value, always fresh -``` - -See [Watching](watching.md) for more patterns. - -## Async - -All APIs have async equivalents: - -```python -from opendecree import AsyncConfigClient - -async with AsyncConfigClient("localhost:9090", subject="myapp") as client: - fee = await client.get("tenant-id", "payments.fee") - retries = await client.get("tenant-id", "payments.retries", int) -``` - -See [Async Usage](async.md) for the full async API. - -## Error handling - -```python -from opendecree import ConfigClient, NotFoundError, LockedError - -with ConfigClient("localhost:9090", subject="myapp") as client: - try: - val = client.get("tenant-id", "nonexistent.field") - except NotFoundError: - print("Field not found") - except LockedError: - print("Field is locked") -``` - -## Next steps - -- [Configuration](configuration.md) — all client options (auth, TLS, retry, timeouts) -- [Watching](watching.md) — live subscriptions and change patterns -- [Async Usage](async.md) — async client and watcher -- [OpenDecree concepts](https://github.com/opendecree/decree) — schemas, typed values, versioning diff --git a/sdk/docs/watching.md b/sdk/docs/watching.md deleted file mode 100644 index 6541ab7..0000000 --- a/sdk/docs/watching.md +++ /dev/null @@ -1,161 +0,0 @@ -# Watching for Changes - -Live config subscriptions via `ConfigWatcher` and `WatchedField[T]`. - -## Basic usage - -Create a watcher from a client. Register fields before starting the watcher: - -```python -from opendecree import ConfigClient - -with ConfigClient("localhost:9090", subject="myapp") as client: - watcher = client.watch("tenant-id") - fee = watcher.field("payments.fee", float, default=0.01) - enabled = watcher.field("payments.enabled", bool, default=False) - - with watcher: - # Read current values - print(fee.value) # 0.025 (float, always fresh) - print(enabled.value) # True (bool) -``` - -The watcher: -1. Loads the current config snapshot on enter -2. Subscribes to changes via gRPC server-streaming -3. Updates field values atomically in the background -4. Auto-reconnects with exponential backoff on connection loss -5. Stops the background thread on exit - -## WatchedField[T] - -Each registered field returns a `WatchedField[T]` with: - -### `.value` — current value - -```python -fee = watcher.field("payments.fee", float, default=0.01) -print(fee.value) # always the latest value, thread-safe -``` - -### `__bool__` — natural conditionals - -```python -enabled = watcher.field("payments.enabled", bool, default=False) - -if enabled: # uses __bool__, checks the live value - print("Feature is enabled") -``` - -Falsy values: `False`, `0`, `0.0`, `""`, `None`. - -### `on_change` — callbacks - -```python -@fee.on_change -def handle_fee_change(old: float, new: float): - print(f"Fee changed: {old} -> {new}") -``` - -Callbacks run on the watcher's background thread. Keep them fast — slow callbacks block other field updates. - -### `changes()` — blocking iterator - -```python -for change in fee.changes(): - print(f"{change.field_path}: {change.old_value} -> {change.new_value}") -``` - -The iterator blocks until a change arrives. It stops when the watcher exits. - -## Supported types - -| Type | Example | Default suggestion | -|------|---------|-------------------| -| `str` | `"hello"` | `""` | -| `int` | `42` | `0` | -| `float` | `3.14` | `0.0` | -| `bool` | `True` | `False` | -| `timedelta` | `timedelta(seconds=30)` | `timedelta()` | - -## Lifecycle - -Register fields **before** starting the watcher. Calling `field()` after `start()` raises `RuntimeError`: - -```python -watcher = client.watch("tenant-id") - -# Register fields first -fee = watcher.field("payments.fee", float, default=0.01) - -# Then start — loads snapshot and subscribes -with watcher: - print(fee.value) -``` - -## Auto-reconnect - -If the gRPC stream drops (server restart, network issue), the watcher automatically reconnects with exponential backoff: - -- Initial delay: 1 second -- Maximum delay: 30 seconds -- Multiplier: 2x -- Jitter: 0.5x–1.5x - -During reconnection, `field.value` returns the last known value. No action needed from your code. - -## Multiple watchers - -You can create multiple watchers for different tenants: - -```python -with ConfigClient("localhost:9090", subject="myapp") as client: - watcher_a = client.watch("tenant-a") - watcher_b = client.watch("tenant-b") - fee_a = watcher_a.field("payments.fee", float, default=0.01) - fee_b = watcher_b.field("payments.fee", float, default=0.01) - - with watcher_a, watcher_b: - # Both update independently - print(fee_a.value, fee_b.value) -``` - -## Fork safety - -gRPC channels are **not fork-safe**. Do not create a `ConfigClient` (or `AsyncConfigClient`) before -calling `os.fork()` — this includes implicit forks from `multiprocessing.Pool`, Gunicorn workers, -and similar process-spawning frameworks. - -After a fork, the child inherits the open gRPC channel. The channel's internal threads and file -descriptors are in an undefined state, which can cause hangs, crashes, or silent data corruption. - -**Fix: create the client inside the worker, not before forking.** - -```python -from multiprocessing import Pool -from opendecree import ConfigClient - -def worker(tenant_id: str) -> str: - # Safe — client created after fork - with ConfigClient("localhost:9090", subject="myapp") as client: - return client.get(tenant_id, "payments.fee") - -with Pool(4) as pool: - results = pool.map(worker, ["tenant-a", "tenant-b"]) -``` - -If you must use `multiprocessing`, prefer the `spawn` start method (default on macOS and Windows) -over `fork` — it avoids inheriting the parent's file descriptors entirely: - -```python -import multiprocessing -multiprocessing.set_start_method("spawn") -``` - -The same restriction applies to `ConfigWatcher`: the background thread does not survive a fork. -Stop the watcher before forking, or start it inside the child process. - -## Next steps - -- [Async Usage](async.md) — async watcher with `async for` iteration -- [Configuration](configuration.md) — client options (auth, TLS, retry) diff --git a/sdk/pyproject.toml b/sdk/pyproject.toml index 60b1554..4191316 100644 --- a/sdk/pyproject.toml +++ b/sdk/pyproject.toml @@ -43,7 +43,6 @@ dev = [ "mypy-protobuf>=5.1.0", "ruff>=0.15.16", "grpcio-tools>=1.81.0", - "pdoc>=16.0.0", ] [project.urls]