From 5793a6a4469b2f94843385766eb40ad47938aa8f Mon Sep 17 00:00:00 2001 From: zeevdr Date: Wed, 3 Jun 2026 10:09:29 +0300 Subject: [PATCH] docs: add mkdocs-material site with GH Pages deploy Add MkDocs Material documentation site targeting https://opendecree.github.io/decree-python. - mkdocs.yml with Material theme (teal/cyan palette), mkdocstrings plugin, nav structure - docs/index.md: intro, install, quick start, examples table - docs/guide/connect.md: all client options (auth, TLS, retry, timeouts, OTel, errors) - docs/guide/watch.md: watcher lifecycle, WatchedField API, async, fork safety - docs/api/index.md: auto-generated reference via mkdocstrings for all public classes and errors - .github/workflows/docs.yml: builds and deploys to GH Pages on push to main and version tags - sdk/pyproject.toml: adds docs optional-dependencies group, updates Documentation URL - README.md: adds docs badge linking to the site Closes #8 Co-Authored-By: Claude --- .github/workflows/docs.yml | 35 +++++++ README.md | 1 + docs/api/index.md | 155 ++++++++++++++++++++++++++++ docs/guide/connect.md | 203 +++++++++++++++++++++++++++++++++++++ docs/guide/watch.md | 163 +++++++++++++++++++++++++++++ docs/index.md | 87 ++++++++++++++++ mkdocs.yml | 62 +++++++++++ sdk/pyproject.toml | 6 +- 8 files changed, 711 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/docs.yml create mode 100644 docs/api/index.md create mode 100644 docs/guide/connect.md create mode 100644 docs/guide/watch.md create mode 100644 docs/index.md create mode 100644 mkdocs.yml diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml new file mode 100644 index 0000000..adb0a00 --- /dev/null +++ b/.github/workflows/docs.yml @@ -0,0 +1,35 @@ +name: Deploy Docs + +on: + push: + branches: [main] + tags: ["v*.*.*"] + +permissions: + contents: read + pages: write + id-token: write + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - run: pip install mkdocs-material mkdocstrings[python] + - run: mkdocs build + - uses: actions/upload-pages-artifact@v3 + with: + path: site/ + + deploy: + needs: build + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + runs-on: ubuntu-latest + steps: + - uses: actions/deploy-pages@v4 + id: deployment diff --git a/README.md b/README.md index 762c55b..89550a3 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,7 @@ [![License](https://img.shields.io/github/license/opendecree/decree-python)](LICENSE) [![Project Status: WIP](https://www.repostatus.org/badges/latest/wip.svg)](https://www.repostatus.org/#wip) [![codecov](https://codecov.io/gh/opendecree/decree-python/graph/badge.svg)](https://codecov.io/gh/opendecree/decree-python) +[![Docs](https://img.shields.io/badge/docs-opendecree.github.io-teal)](https://opendecree.github.io/decree-python) [![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/opendecree/decree-python) Python SDK for [OpenDecree](https://github.com/opendecree/decree) — schema-driven configuration management. diff --git a/docs/api/index.md b/docs/api/index.md new file mode 100644 index 0000000..c5e5dd4 --- /dev/null +++ b/docs/api/index.md @@ -0,0 +1,155 @@ +# API Reference + +Auto-generated from source docstrings. + +## Clients + +### ConfigClient + +::: opendecree.ConfigClient + options: + show_source: false + +### AsyncConfigClient + +::: opendecree.AsyncConfigClient + options: + show_source: false + +## Watchers + +### ConfigWatcher + +::: opendecree.ConfigWatcher + options: + show_source: false + +### WatchedField + +::: opendecree.WatchedField + options: + show_source: false + +### AsyncConfigWatcher + +::: opendecree.AsyncConfigWatcher + options: + show_source: false + +### AsyncWatchedField + +::: opendecree.AsyncWatchedField + options: + show_source: false + +## Data Types + +### Change + +::: opendecree.Change + options: + show_source: false + +### FieldUpdate + +::: opendecree.FieldUpdate + options: + show_source: false + +### ServerVersion + +::: opendecree.ServerVersion + options: + show_source: false + +### RetryConfig + +::: opendecree.RetryConfig + options: + show_source: false + +## Exceptions + +### DecreeError + +::: opendecree.DecreeError + options: + show_source: false + +### NotFoundError + +::: opendecree.NotFoundError + options: + show_source: false + +### AlreadyExistsError + +::: opendecree.AlreadyExistsError + options: + show_source: false + +### InvalidArgumentError + +::: opendecree.InvalidArgumentError + options: + show_source: false + +### LockedError + +::: opendecree.LockedError + options: + show_source: false + +### ChecksumMismatchError + +::: opendecree.ChecksumMismatchError + options: + show_source: false + +### PermissionDeniedError + +::: opendecree.PermissionDeniedError + options: + show_source: false + +### UnavailableError + +::: opendecree.UnavailableError + options: + show_source: false + +### TypeMismatchError + +::: opendecree.TypeMismatchError + options: + show_source: false + +### IncompatibleServerError + +::: opendecree.IncompatibleServerError + options: + show_source: false + +### TimeoutError + +::: opendecree.TimeoutError + options: + show_source: false + +### ResourceExhaustedError + +::: opendecree.ResourceExhaustedError + options: + show_source: false + +### CancelledError + +::: opendecree.CancelledError + options: + show_source: false + +### UnimplementedError + +::: opendecree.UnimplementedError + options: + show_source: false diff --git a/docs/guide/connect.md b/docs/guide/connect.md new file mode 100644 index 0000000..d3dbb31 --- /dev/null +++ b/docs/guide/connect.md @@ -0,0 +1,203 @@ +# Connecting + +How to create and configure `ConfigClient` and `AsyncConfigClient`. + +## Basic connection + +```python +from opendecree import ConfigClient + +with ConfigClient("localhost:9090", subject="myapp") as client: + val = client.get("tenant-id", "payments.fee") +``` + +Use `ConfigClient` as a context manager — the gRPC channel opens on enter and closes on exit. +For async code, use `AsyncConfigClient` with `async with`. + +## Constructor options + +```python +ConfigClient( + target, # gRPC server address (host:port) + *, + subject: str | None = None, # x-subject metadata header + role: str = "superadmin", # x-role metadata header + tenant_id: str | None = None, # x-tenant-id metadata header + token: str | None = None, # Bearer token (JWT mode) + insecure: bool = True, # plaintext — default for local dev + credentials = None, # grpc.ChannelCredentials for TLS + timeout: float = 10.0, # per-RPC timeout in seconds + retry: RetryConfig | None = ..., # retry config (None to disable) + check_version: bool = False, # verify server version on first call + otel: bool = False, # wire OpenTelemetry interceptor +) +``` + +`AsyncConfigClient` accepts the same options. + +## Authentication + +### Metadata headers (default) + +The server reads identity from gRPC metadata headers. No tokens required. + +```python +client = ConfigClient( + "localhost:9090", + subject="myapp", # who is making the request + role="superadmin", # role (default: superadmin) +) +``` + +For non-superadmin roles, include `tenant_id` to scope access: + +```python +client = ConfigClient( + "localhost:9090", + subject="alice", + role="admin", + tenant_id="tenant-123", +) +``` + +To allow access to multiple tenants, pass a comma-separated list: + +```python +client = ConfigClient( + "localhost:9090", + subject="alice", + role="admin", + tenant_id="tenant-123,tenant-456", +) +``` + +### Bearer token (JWT mode) + +If the server has JWT auth enabled, pass a token instead: + +```python +client = ConfigClient( + "localhost:9090", + token="eyJhbGciOiJS...", +) +``` + +When `token` is set, `subject`, `role`, and `tenant_id` are ignored — access is +determined by the JWT `tenant_ids` claim. + +!!! warning "TLS required in production" + Sending a bearer token over a plaintext channel will raise a `UserWarning`. Use + `insecure=False` with proper TLS credentials when running in production. + +## TLS + +```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 errors are retried automatically with exponential backoff and jitter. The default +policy retries up to 3 times on `UNAVAILABLE`, `DEADLINE_EXCEEDED`, and `RESOURCE_EXHAUSTED`. + +```python +from opendecree import ConfigClient, RetryConfig + +client = ConfigClient( + "localhost:9090", + retry=RetryConfig( + max_attempts=5, + initial_backoff=0.2, + max_backoff=10.0, + multiplier=2.0, + ), +) + +# Disable retry entirely +client = ConfigClient("localhost:9090", retry=None) +``` + +**Reads** (`get`, `get_all`) retry on both `UNAVAILABLE` and `DEADLINE_EXCEEDED` — reads +are idempotent. + +**Writes** (`set`, `set_many`, `set_null`) retry only on `UNAVAILABLE` by default, because +`DEADLINE_EXCEEDED` does not guarantee the server hasn't already applied the write. To opt +a write into `DEADLINE_EXCEEDED` retry, pass an `idempotency_key`: + +```python +import uuid + +client.set( + "tenant-id", + "feature_flags.dark_mode", + "true", + idempotency_key=str(uuid.uuid4()), +) +``` + +Only use `idempotency_key` when a duplicate apply is harmless for your use case. + +## Timeouts + +The `timeout` parameter sets the default per-RPC deadline in seconds (default: 10): + +```python +client = ConfigClient("localhost:9090", timeout=30.0) +``` + +## OpenTelemetry + +Pass `otel=True` to trace all RPCs with OpenTelemetry: + +```python +client = ConfigClient("localhost:9090", otel=True) +``` + +Requires the optional extra: + +```bash +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. + +## Error handling + +All exceptions inherit from `DecreeError`: + +| Exception | gRPC Code | When | +|-----------|-----------|------| +| `NotFoundError` | NOT_FOUND | Field or tenant does not 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 in typed getter | +| `IncompatibleServerError` | — | Server version mismatch | +| `TimeoutError` | DEADLINE_EXCEEDED | RPC deadline exceeded | + +```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") +``` diff --git a/docs/guide/watch.md b/docs/guide/watch.md new file mode 100644 index 0000000..2a67013 --- /dev/null +++ b/docs/guide/watch.md @@ -0,0 +1,163 @@ +# Watching for Changes + +Live config subscriptions via `ConfigWatcher` and `WatchedField[T]`. + +## Basic usage + +Create a watcher from a client, register fields, then use it as a context manager: + +```python +from opendecree import ConfigClient + +with ConfigClient("localhost:9090", subject="myapp") as client: + with client.watch("tenant-id") as watcher: + fee = watcher.field("payments.fee", float, default=0.01) + enabled = watcher.field("payments.enabled", bool, default=False) + + print(fee.value) # current value (float), always fresh + print(enabled.value) # current value (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 a background thread +4. Auto-reconnects with exponential backoff on connection loss +5. Stops the background thread on exit + +!!! note "Register fields before starting" + Call `watcher.field()` before entering the `with watcher:` block. Registering fields + after `start()` raises `RuntimeError`. + +## WatchedField[T] + +Each registered field returns a `WatchedField[T]` instance. + +### Reading the current value + +```python +fee = watcher.field("payments.fee", float, default=0.01) +print(fee.value) # always the latest value, thread-safe +``` + +### Boolean checks + +`WatchedField` implements `__bool__`, so it works naturally in conditionals: + +```python +enabled = watcher.field("payments.enabled", bool, default=False) + +if enabled: + print("Feature is enabled") +``` + +Falsy values: `False`, `0`, `0.0`, `""`, `None`. + +### Callbacks with `on_change` + +```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 delay +processing of subsequent field updates. + +### Blocking iteration with `changes()` + +```python +for change in fee.changes(): + print(f"{change.field_path}: {change.old_value} -> {change.new_value}") +``` + +The iterator blocks until a change arrives and stops when the watcher exits. + +## Supported field types + +| Type | Example value | Suggested default | +|------|--------------|-------------------| +| `str` | `"hello"` | `""` | +| `int` | `42` | `0` | +| `float` | `3.14` | `0.0` | +| `bool` | `True` | `False` | +| `timedelta` | `timedelta(seconds=30)` | `timedelta()` | + +## Auto-reconnect + +If the gRPC stream drops (server restart, network blip), the watcher reconnects automatically +with exponential backoff: + +- Initial delay: 1 second +- Maximum delay: 30 seconds +- Multiplier: 2× +- Jitter: 0.5×–1.5× + +During reconnection, `field.value` returns the last known value. No action needed from your code. + +## Multiple tenants + +Each watcher is scoped to one tenant. Use multiple watchers for multiple 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: + print(fee_a.value, fee_b.value) +``` + +## Async watcher + +`AsyncConfigClient` provides an equivalent `AsyncConfigWatcher`: + +```python +from opendecree import AsyncConfigClient + +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) + print(fee.value) + + # Async iteration + async for change in fee.changes(): + print(f"{change.old_value} -> {change.new_value}") +``` + +Callbacks work the same as the sync watcher — they are plain functions, not coroutines. + +## Fork safety + +gRPC channels are **not fork-safe**. Do not create a `ConfigClient` before calling `os.fork()`. +This includes implicit forks from `multiprocessing.Pool`, Gunicorn workers, and similar frameworks. + +After a fork, the child inherits the open channel in an undefined state, which can cause hangs, +crashes, or silent data corruption. + +**Fix: create the client inside the worker process.** + +```python +from multiprocessing import Pool +from opendecree import ConfigClient + +def worker(tenant_id: str) -> str: + 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): + +```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. diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000..1429972 --- /dev/null +++ b/docs/index.md @@ -0,0 +1,87 @@ +# OpenDecree Python SDK + +[![CI](https://github.com/opendecree/decree-python/actions/workflows/ci.yml/badge.svg)](https://github.com/opendecree/decree-python/actions/workflows/ci.yml) +[![PyPI](https://img.shields.io/pypi/v/opendecree)](https://pypi.org/project/opendecree/) +[![Python](https://img.shields.io/pypi/pyversions/opendecree)](https://pypi.org/project/opendecree/) +[![License](https://img.shields.io/github/license/opendecree/decree-python)](https://github.com/opendecree/decree-python/blob/main/LICENSE) + +Python SDK for [OpenDecree](https://github.com/opendecree/decree) — schema-driven configuration management. + +!!! warning "Alpha" + This SDK is under active development. APIs and behavior may change without notice between versions. + +## Install + +```bash +pip install opendecree +``` + +## Requirements + +- Python 3.11+ +- A running OpenDecree server (v0.3.0+) + +## Quick Start + +```python +from opendecree import ConfigClient + +with ConfigClient("localhost:9090", subject="myapp") as client: + # Get a config value (returns str by default) + fee = client.get("tenant-id", "payments.fee") + + # Typed reads — pass the target type + retries = client.get("tenant-id", "payments.retries", int) + enabled = client.get("tenant-id", "payments.enabled", bool) + + # Write a value + client.set("tenant-id", "payments.fee", "0.5%") +``` + +The `with` block manages the gRPC channel lifecycle — it opens on enter and closes on exit. + +## Watch for Changes + +```python +with ConfigClient("localhost:9090", subject="myapp") as client: + with client.watch("tenant-id") as watcher: + fee = watcher.field("payments.fee", float, default=0.01) + enabled = watcher.field("payments.enabled", bool, default=False) + + if enabled: + print(f"Current fee: {fee.value}") + + @fee.on_change + def on_fee_change(old: float, new: float): + print(f"Fee changed: {old} -> {new}") +``` + +## Async + +```python +from opendecree import AsyncConfigClient + +async with AsyncConfigClient("localhost:9090", subject="myapp") as client: + val = await client.get("tenant-id", "payments.fee") + retries = await client.get("tenant-id", "payments.retries", int) +``` + +## Examples + +Runnable examples are available in the [`examples/`](https://github.com/opendecree/decree-python/tree/main/examples) directory of the repository. + +| Example | What it shows | +|---------|--------------| +| [quickstart](https://github.com/opendecree/decree-python/tree/main/examples/quickstart) | Context manager, typed `get()`, `set()` | +| [async-client](https://github.com/opendecree/decree-python/tree/main/examples/async-client) | `async with`, `await`, `asyncio.gather()` | +| [live-config](https://github.com/opendecree/decree-python/tree/main/examples/live-config) | `ConfigWatcher`, `@on_change`, `changes()` | +| [fastapi-integration](https://github.com/opendecree/decree-python/tree/main/examples/fastapi-integration) | Async watcher as FastAPI lifespan dependency | +| [error-handling](https://github.com/opendecree/decree-python/tree/main/examples/error-handling) | `RetryConfig`, `nullable=True`, error hierarchy | + +## Next Steps + +- [Connecting](guide/connect.md) — all client options (auth, TLS, retry, timeouts) +- [Watching](guide/watch.md) — live subscriptions and change patterns +- [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 new file mode 100644 index 0000000..fb0acf2 --- /dev/null +++ b/mkdocs.yml @@ -0,0 +1,62 @@ +site_name: OpenDecree Python SDK +site_description: Python SDK for OpenDecree — schema-driven configuration management +site_url: https://opendecree.github.io/decree-python +repo_url: https://github.com/opendecree/decree-python +repo_name: opendecree/decree-python +edit_uri: edit/main/docs/ + +theme: + name: material + palette: + - scheme: default + primary: teal + accent: cyan + toggle: + icon: material/brightness-7 + name: Switch to dark mode + - scheme: slate + primary: teal + accent: cyan + toggle: + icon: material/brightness-4 + name: Switch to light mode + features: + - navigation.tabs + - navigation.sections + - navigation.top + - content.code.copy + - content.code.annotate + +plugins: + - search + - mkdocstrings: + handlers: + python: + paths: [sdk/src] + options: + show_source: false + show_root_heading: true + show_symbol_type_heading: true + show_symbol_type_toc: true + docstring_style: google + merge_init_into_class: true + +nav: + - Home: index.md + - Guide: + - Connecting: guide/connect.md + - Watching: guide/watch.md + - API Reference: api/index.md + +markdown_extensions: + - admonition + - pymdownx.highlight: + anchor_linenums: true + - pymdownx.inlinehilite + - pymdownx.snippets + - pymdownx.superfences + - pymdownx.tabbed: + alternate_style: true + - tables + - toc: + permalink: true diff --git a/sdk/pyproject.toml b/sdk/pyproject.toml index 5f82475..9c299e4 100644 --- a/sdk/pyproject.toml +++ b/sdk/pyproject.toml @@ -31,6 +31,10 @@ dependencies = [ otel = [ "opentelemetry-instrumentation-grpc>=0.50b0", ] +docs = [ + "mkdocs-material>=9.5", + "mkdocstrings[python]>=0.27", +] dev = [ "pytest>=9.0.3", "pytest-cov>=7.1.0", @@ -44,7 +48,7 @@ dev = [ [project.urls] Homepage = "https://github.com/opendecree/decree-python" -Documentation = "https://github.com/opendecree/decree" +Documentation = "https://opendecree.github.io/decree-python" Repository = "https://github.com/opendecree/decree-python" Issues = "https://github.com/opendecree/decree-python/issues"