Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
99 changes: 93 additions & 6 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -109,31 +112,115 @@ 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:
python-version: "3.12"
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
python -m py_compile examples/live-config/main.py
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
Expand Down
21 changes: 13 additions & 8 deletions examples/async-client/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down
4 changes: 0 additions & 4 deletions examples/error-handling/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 ===")

Expand Down
33 changes: 33 additions & 0 deletions examples/live-config/test_live_config.py
Original file line number Diff line number Diff line change
@@ -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
12 changes: 7 additions & 5 deletions examples/quickstart/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
1 change: 1 addition & 0 deletions examples/seed.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
30 changes: 23 additions & 7 deletions examples/setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
python setup.py
"""

import json
import os
import subprocess
import sys
Expand All @@ -21,25 +22,40 @@ 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,
)
if result.returncode != 0:
print(f"Error seeding: {result.stderr}", file=sys.stderr)
sys.exit(1)

# Parse tenant ID from output (line: "Tenant: <id> (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", "<id>", "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)


Expand Down