diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 767f9f8..524c164 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,8 +1,11 @@ # CI pipeline for the OpenDecree Python SDK. # # Jobs: lint, typecheck, test (matrix: 3.11-3.13), examples → check (alls-green gate) -# Integration job is optional — runs on workflow_dispatch or when -# DECREE_TEST_ADDR secret is set, starting a live server via docker-compose. +# The examples job compile-checks every example, then starts a live decree +# server via docker-compose, seeds it, and runs each example end-to-end — +# so a broken runnable example (e.g. wrong set_many() argument type) fails CI. +# Integration job is optional — runs on workflow_dispatch with run-integration +# enabled, exercising the SDK's own integration test suite against a live server. name: CI @@ -109,13 +112,23 @@ jobs: examples: name: Examples runs-on: ubuntu-latest - timeout-minutes: 10 + timeout-minutes: 20 + permissions: + contents: read + packages: read steps: - - name: Checkout + - name: Checkout decree-python uses: actions/checkout@v6 with: persist-credentials: false + - name: Checkout decree (for docker-compose + server + CLI) + uses: actions/checkout@v6 + with: + repository: opendecree/decree + path: decree + persist-credentials: false + - name: Set up Python uses: actions/setup-python@v6 with: @@ -123,10 +136,13 @@ jobs: cache: pip cache-dependency-path: sdk/pyproject.toml - - name: Install SDK - run: pip install -e sdk/ + - name: Install SDK with dev dependencies + run: pip install -e "sdk[dev]" - name: Compile-check all examples + # Syntax-only — catches typos but not runtime errors (e.g. iterating + # a dict where a list[FieldUpdate] is expected). The steps below + # actually execute the examples against a live server. run: | python -m py_compile examples/quickstart/main.py python -m py_compile examples/async-client/main.py @@ -134,6 +150,77 @@ jobs: python -m py_compile examples/error-handling/main.py python -m py_compile examples/setup.py + - name: Log in to ghcr.io + uses: docker/login-action@v4 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v4 + with: + driver: docker-container + driver-opts: network=host + + - name: Build server image + uses: docker/build-push-action@v7 + with: + context: decree + file: decree/build/Dockerfile + load: true + tags: decree-server + cache-from: | + type=registry,ref=ghcr.io/opendecree/decree:buildcache + type=gha,scope=py-examples-server + cache-to: type=gha,scope=py-examples-server,mode=max + + - name: Build tools image (for migrations) + uses: docker/build-push-action@v7 + with: + context: decree/build + file: decree/build/Dockerfile.tools + load: true + tags: decree-tools + cache-from: | + type=registry,ref=ghcr.io/opendecree/decree-tools:buildcache + type=gha,scope=py-examples-tools + cache-to: type=gha,scope=py-examples-tools,mode=max + + - name: Set up Go (for CLI build) + uses: actions/setup-go@v6 + with: + go-version-file: decree/cmd/decree/go.mod + cache-dependency-path: decree/cmd/decree/go.sum + + - name: Build decree CLI + # examples/setup.py shells out to `decree seed` to provision the + # schema/tenant/config that the examples read and write. + run: | + cd decree/cmd/decree && go build -o "$HOME/go/bin/decree" . + echo "$HOME/go/bin" >> "$GITHUB_PATH" + + - name: Start decree service + run: docker compose -f decree/docker-compose.yml up -d --wait service + env: + SERVICE_IMAGE: decree-server + TOOLS_IMAGE: decree-tools + + - name: Seed example data + run: cd examples && python setup.py + env: + DECREE_ADDR: "localhost:9090" + + - name: Run examples against the live server + # Executes each runnable example end-to-end (including async-client's + # set_many call) so a broken example fails CI instead of silently + # passing a syntax-only check — see opendecree/decree-python#133. + run: cd examples && make test + + - name: Tear down services + if: always() + run: docker compose -f decree/docker-compose.yml down -v + wheel-check: name: Wheel contents runs-on: ubuntu-latest diff --git a/examples/async-client/main.py b/examples/async-client/main.py index 6009e78..f8e8fde 100644 --- a/examples/async-client/main.py +++ b/examples/async-client/main.py @@ -14,7 +14,7 @@ from datetime import timedelta from pathlib import Path -from opendecree import AsyncConfigClient +from opendecree import AsyncConfigClient, FieldUpdate async def main() -> None: @@ -49,18 +49,23 @@ async def main() -> None: print(f" app.debug: {debug}") print(f" server.rate_limit: {rate_limit}") - # Atomic multi-write. + # Atomic multi-write — each update is a FieldUpdate, not a plain dict. + # Values are always strings — the server validates against the + # field's declared type, so this works cleanly for string fields. await client.set_many( tenant_id, - {"app.debug": "true", "server.rate_limit": "200"}, + [ + FieldUpdate("app.name", "Acme Corp App (async)"), + FieldUpdate("payments.currency", "EUR"), + ], description="async example update", ) - print("\nUpdated app.debug=true, server.rate_limit=200") + print("\nUpdated app.name and payments.currency") - debug = await client.get(tenant_id, "app.debug", bool) - rate_limit = await client.get(tenant_id, "server.rate_limit", int) - print(f" app.debug: {debug}") - print(f" server.rate_limit: {rate_limit}") + name = await client.get(tenant_id, "app.name") + currency = await client.get(tenant_id, "payments.currency") + print(f" app.name: {name}") + print(f" payments.currency: {currency}") def get_tenant_id() -> str: diff --git a/examples/error-handling/main.py b/examples/error-handling/main.py index 09f87db..dae4935 100644 --- a/examples/error-handling/main.py +++ b/examples/error-handling/main.py @@ -47,10 +47,6 @@ def main() -> None: value = client.get(tenant_id, "app.debug", str, nullable=True) print(f"app.debug (after set_null): {value!r}") - # Restore it. - client.set(tenant_id, "app.debug", "false") - print(f"app.debug (restored): {client.get(tenant_id, 'app.debug', bool)!r}") - # --- Error hierarchy --- print("\n=== Error hierarchy ===") diff --git a/examples/live-config/test_live_config.py b/examples/live-config/test_live_config.py new file mode 100644 index 0000000..075bcbe --- /dev/null +++ b/examples/live-config/test_live_config.py @@ -0,0 +1,33 @@ +"""Smoke test for the live-config example.""" + +import signal +import subprocess +import sys + +import pytest + + +@pytest.mark.example +def test_live_config_runs() -> None: + """Verify the example connects, prints current values, then starts watching.""" + proc = subprocess.Popen( + [sys.executable, "main.py"], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + try: + stdout, stderr = proc.communicate(timeout=10) + except subprocess.TimeoutExpired: + # Expected — the example blocks on changes(). SIGINT triggers its + # own handler (signal.signal(... lambda *_: sys.exit(0))), which + # flushes stdout on the way out. + proc.send_signal(signal.SIGINT) + try: + stdout, stderr = proc.communicate(timeout=10) + except subprocess.TimeoutExpired: + proc.kill() + stdout, stderr = proc.communicate() + + assert "Current values:" in stdout, f"stdout: {stdout}\nstderr: {stderr}" + assert "Watching for changes" in stdout diff --git a/examples/quickstart/main.py b/examples/quickstart/main.py index e265946..f749443 100644 --- a/examples/quickstart/main.py +++ b/examples/quickstart/main.py @@ -37,12 +37,14 @@ def main() -> None: fee_rate = client.get(tenant_id, "payments.fee_rate", float) print(f"payments.fee_rate: {fee_rate}") - # set() and set_many() for writes. - client.set(tenant_id, "app.debug", "true") - print("\nSet app.debug = true") + # set() and set_many() for writes. The value is always a string — + # the server validates it against the field's declared type, so + # this works cleanly for string fields like app.name. + client.set(tenant_id, "app.name", "Acme Corp App (updated)") + print("\nSet app.name = 'Acme Corp App (updated)'") - debug = client.get(tenant_id, "app.debug", bool) - print(f"app.debug: {debug}") + name = client.get(tenant_id, "app.name") + print(f"app.name: {name}") def get_tenant_id() -> str: diff --git a/examples/seed.yaml b/examples/seed.yaml index 4b6ce86..55fa925 100644 --- a/examples/seed.yaml +++ b/examples/seed.yaml @@ -9,6 +9,7 @@ schema: app.debug: type: bool description: Enable debug logging + nullable: true features.dark_mode: type: bool description: Enable dark mode UI diff --git a/examples/setup.py b/examples/setup.py index d792809..d971a2a 100644 --- a/examples/setup.py +++ b/examples/setup.py @@ -8,6 +8,7 @@ python setup.py """ +import json import os import subprocess import sys @@ -21,8 +22,20 @@ def main() -> None: # Use the decree CLI to seed — it handles schema creation, tenant # creation, and config import in one command. + # --auto-publish: tenants can only be created against a published + # schema version, and imported versions start as unpublished drafts. + # --subject: the server rejects unauthenticated requests (empty + # x-subject), and the CLI does not set one by default. result = subprocess.run( - ["decree", "seed", "--addr", addr, str(seed_file)], + [ + "decree", "seed", + "--server", addr, + "--insecure", + "--auto-publish", + "--subject", "examples-setup", + "--output", "json", + str(seed_file), + ], capture_output=True, text=True, ) @@ -30,16 +43,19 @@ def main() -> None: print(f"Error seeding: {result.stderr}", file=sys.stderr) sys.exit(1) - # Parse tenant ID from output (line: "Tenant: (created=true)") - for line in result.stdout.splitlines(): - if line.startswith("Tenant:"): - tenant_id = line.split()[1] + # `decree seed -o json` prints a [][string] table: a header row + # ["RESOURCE", "ID", "CREATED", "DETAILS"] followed by one row per + # resource, e.g. ["tenant", "", "true", ""]. + rows = json.loads(result.stdout) + for row in rows: + if row[0] == "tenant": + tenant_id = row[1] tenant_id_file.write_text(tenant_id) - print(result.stdout, end="") + print(f"Tenant: {tenant_id}") print("Tenant ID written to .tenant-id") return - print(f"Could not parse tenant ID from output:\n{result.stdout}", file=sys.stderr) + print(f"Could not find tenant row in output:\n{result.stdout}", file=sys.stderr) sys.exit(1)