Skip to content

Commit d007e36

Browse files
zeevdrclaude
andcommitted
Examples, multi-tenant docs, alpha disclaimer
Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 687bca7 commit d007e36

24 files changed

Lines changed: 897 additions & 1 deletion

File tree

.github/workflows/ci.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ on:
55
branches: [main]
66
pull_request:
77
branches: [main]
8+
workflow_call:
89

910
jobs:
1011
lint:

README.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@
88

99
Python SDK for [OpenDecree](https://github.com/zeevdr/decree) — schema-driven configuration management.
1010

11+
> **Alpha** — This SDK is under active development. APIs and behavior may change without notice between versions.
12+
1113
## Install
1214

1315
```bash
@@ -57,6 +59,18 @@ async with AsyncConfigClient("localhost:9090", subject="myapp") as client:
5759
retries = await client.get("tenant-id", "payments.retries", int)
5860
```
5961

62+
## Examples
63+
64+
Runnable examples in the [`examples/`](examples/) directory:
65+
66+
| Example | What it shows |
67+
|---------|--------------|
68+
| [quickstart](examples/quickstart/) | Context manager, typed `get()`, `set()` |
69+
| [async-client](examples/async-client/) | `async with`, `await`, `asyncio.gather()` |
70+
| [live-config](examples/live-config/) | `ConfigWatcher`, `@on_change`, `changes()` |
71+
| [fastapi-integration](examples/fastapi-integration/) | Async watcher as FastAPI lifespan dependency |
72+
| [error-handling](examples/error-handling/) | `RetryConfig`, `nullable=True`, error hierarchy |
73+
6074
## Documentation
6175

6276
- [Quick Start](sdk/docs/quickstart.md)

examples/.gitignore

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
.tenant-id
2+
__pycache__/
3+
*.pyc
4+
.venv/

examples/Makefile

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
.PHONY: up setup test down clean help
2+
3+
EXAMPLES := quickstart async-client live-config error-handling
4+
5+
## up: Start the decree server (postgres + redis + migrations + service)
6+
up:
7+
cd ../../decree && docker compose up -d --wait service
8+
9+
## setup: Seed example schema, tenant, and config (writes .tenant-id)
10+
setup: up
11+
python setup.py
12+
13+
## test: Run all examples as smoke tests (run 'make setup' first)
14+
test:
15+
@for dir in $(EXAMPLES); do \
16+
echo "=== $$dir ==="; \
17+
(cd $$dir && python -m pytest test_*.py -v) || exit 1; \
18+
done
19+
20+
## down: Stop the server and remove volumes
21+
down:
22+
cd ../../decree && docker compose down -v
23+
24+
## clean: Remove generated files
25+
clean:
26+
rm -f .tenant-id
27+
find . -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true
28+
29+
## help: Show this help
30+
help:
31+
@grep -E '^## ' $(MAKEFILE_LIST) | sed 's/## //' | column -t -s ':'

examples/README.md

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
# OpenDecree Python SDK Examples
2+
3+
Runnable examples demonstrating the OpenDecree Python SDK.
4+
5+
## Setup
6+
7+
Start the decree server and seed example data:
8+
9+
```bash
10+
# From this directory
11+
make setup
12+
```
13+
14+
This starts PostgreSQL, Redis, and the decree server via Docker Compose,
15+
then creates an example schema, tenant, and initial config values.
16+
17+
The tenant ID is written to `.tenant-id` — examples read it automatically.
18+
19+
## Prerequisites
20+
21+
```bash
22+
pip install opendecree
23+
```
24+
25+
For the FastAPI example, also install:
26+
```bash
27+
pip install fastapi uvicorn
28+
```
29+
30+
## Examples
31+
32+
| Example | What it shows | Server required |
33+
|---------|--------------|-----------------|
34+
| [quickstart](quickstart/) | Context manager, typed `get()`, `set()` | Yes |
35+
| [async-client](async-client/) | `async with`, `await`, `asyncio.gather()` | Yes |
36+
| [live-config](live-config/) | `ConfigWatcher`, `@on_change` decorator, `changes()` iterator | Yes |
37+
| [fastapi-integration](fastapi-integration/) | `AsyncConfigWatcher` as FastAPI lifespan dependency | Yes |
38+
| [error-handling](error-handling/) | `RetryConfig`, `nullable=True`, error hierarchy | Yes |
39+
40+
## Running an example
41+
42+
```bash
43+
# After make setup:
44+
cd quickstart
45+
python main.py
46+
```
47+
48+
Or run all examples as tests:
49+
50+
```bash
51+
make test
52+
```
53+
54+
## Teardown
55+
56+
```bash
57+
make down
58+
```
59+
60+
## Learn more
61+
62+
- [Python SDK on PyPI](https://pypi.org/project/opendecree/)
63+
- [OpenDecree docs](https://github.com/zeevdr/decree)

examples/async-client/README.md

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
# Async Client
2+
3+
The same quickstart flow using `AsyncConfigClient` with `async`/`await`.
4+
5+
## What it shows
6+
7+
- `AsyncConfigClient` as an async context manager (`async with`)
8+
- `await client.get()` for non-blocking reads
9+
- `asyncio.gather()` for concurrent reads — faster than sequential
10+
- `await client.set_many()` for atomic multi-writes
11+
12+
## Run
13+
14+
```bash
15+
cd examples
16+
make setup
17+
cd async-client
18+
python main.py
19+
```
20+
21+
## Next
22+
23+
- [live-config](../live-config/) — async watcher with live updates
24+
- [fastapi-integration](../fastapi-integration/) — async client in a web app
25+
26+
## Learn more
27+
28+
- [Python SDK on PyPI](https://pypi.org/project/opendecree/)

examples/async-client/main.py

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
#!/usr/bin/env python3
2+
"""Async Client: connect to OpenDecree using asyncio.
3+
4+
Demonstrates the AsyncConfigClient — same API as ConfigClient but fully
5+
async. Uses `async with` for lifecycle and `await` for all operations.
6+
7+
Run:
8+
python main.py
9+
10+
Requires a running decree server with seeded data (see ../README.md).
11+
"""
12+
13+
import asyncio
14+
from datetime import timedelta
15+
from pathlib import Path
16+
17+
from opendecree import AsyncConfigClient
18+
19+
20+
async def main() -> None:
21+
tenant_id = get_tenant_id()
22+
23+
# Async context manager — closes the gRPC channel on exit.
24+
async with AsyncConfigClient("localhost:9090", subject="async-example") as client:
25+
# All operations are awaitable.
26+
name = await client.get(tenant_id, "app.name")
27+
print(f"app.name: {name}")
28+
29+
debug = await client.get(tenant_id, "app.debug", bool)
30+
print(f"app.debug: {debug}")
31+
32+
rate_limit = await client.get(tenant_id, "server.rate_limit", int)
33+
print(f"server.rate_limit: {rate_limit}")
34+
35+
timeout = await client.get(tenant_id, "server.timeout", timedelta)
36+
print(f"server.timeout: {timeout}")
37+
38+
fee_rate = await client.get(tenant_id, "payments.fee_rate", float)
39+
print(f"payments.fee_rate: {fee_rate}")
40+
41+
# Concurrent reads with asyncio.gather — faster than sequential.
42+
print("\nConcurrent reads:")
43+
name, debug, rate_limit = await asyncio.gather(
44+
client.get(tenant_id, "app.name"),
45+
client.get(tenant_id, "app.debug", bool),
46+
client.get(tenant_id, "server.rate_limit", int),
47+
)
48+
print(f" app.name: {name}")
49+
print(f" app.debug: {debug}")
50+
print(f" server.rate_limit: {rate_limit}")
51+
52+
# Atomic multi-write.
53+
await client.set_many(
54+
tenant_id,
55+
{"app.debug": "true", "server.rate_limit": "200"},
56+
description="async example update",
57+
)
58+
print("\nUpdated app.debug=true, server.rate_limit=200")
59+
60+
debug = await client.get(tenant_id, "app.debug", bool)
61+
rate_limit = await client.get(tenant_id, "server.rate_limit", int)
62+
print(f" app.debug: {debug}")
63+
print(f" server.rate_limit: {rate_limit}")
64+
65+
66+
def get_tenant_id() -> str:
67+
import os
68+
69+
if v := os.environ.get("TENANT_ID"):
70+
return v
71+
tenant_file = Path(__file__).parent.parent / ".tenant-id"
72+
if tenant_file.exists():
73+
return tenant_file.read_text().strip()
74+
raise SystemExit("Set TENANT_ID env var or run 'make setup' from the examples directory")
75+
76+
77+
if __name__ == "__main__":
78+
asyncio.run(main())
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
"""Smoke test for the async-client example."""
2+
3+
import subprocess
4+
import sys
5+
6+
import pytest
7+
8+
9+
@pytest.mark.example
10+
def test_async_client_runs() -> None:
11+
"""Verify the async-client example runs without errors."""
12+
result = subprocess.run(
13+
[sys.executable, "main.py"],
14+
capture_output=True,
15+
text=True,
16+
timeout=30,
17+
)
18+
assert result.returncode == 0, f"stdout: {result.stdout}\nstderr: {result.stderr}"
19+
assert "app.name:" in result.stdout
20+
assert "Concurrent reads:" in result.stdout

examples/error-handling/README.md

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
# Error Handling
2+
3+
Retry configuration, nullable fields, and the typed error hierarchy.
4+
5+
## What it shows
6+
7+
- `RetryConfig` — customize retry attempts, backoff, and max delay
8+
- `nullable=True` — return `None` for missing values instead of raising
9+
- `set_null()` — explicitly null a field
10+
- Error hierarchy: `NotFoundError`, `InvalidArgumentError`, `DecreeError` base
11+
- Disabling retry with `retry=None`
12+
13+
## Run
14+
15+
```bash
16+
cd examples
17+
make setup
18+
cd error-handling
19+
python main.py
20+
```
21+
22+
## Error types
23+
24+
| Exception | When |
25+
|-----------|------|
26+
| `NotFoundError` | Field or tenant doesn't exist |
27+
| `InvalidArgumentError` | Value fails schema validation |
28+
| `LockedError` | Field is locked |
29+
| `ChecksumMismatchError` | Optimistic concurrency conflict |
30+
| `PermissionDeniedError` | Auth failure |
31+
| `UnavailableError` | Server unreachable (retryable) |
32+
| `TypeMismatchError` | SDK can't convert value to requested type |
33+
| `DecreeError` | Base class for all of the above |
34+
35+
## Learn more
36+
37+
- [Python SDK on PyPI](https://pypi.org/project/opendecree/)

0 commit comments

Comments
 (0)