Skip to content
Open
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
136 changes: 136 additions & 0 deletions .github/workflows/integration-cls.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
name: Integration (class-based API)

# Controls when the workflow will run
on:
pull_request:
push:
branches:
- main
schedule:
- cron: "0 */6 * * *" # Every 6 hours
workflow_dispatch:
inputs:
restateCommit:
description: "restate commit"
required: false
default: ""
type: string
restateImage:
description: "restate image, superseded by restate commit"
required: false
default: "ghcr.io/restatedev/restate:main"
type: string
serviceImage:
description: "service image, if provided it will skip building the image from sdk main branch"
required: false
default: ""
type: string
workflow_call:
inputs:
restateCommit:
description: "restate commit"
required: false
default: ""
type: string
restateImage:
description: "restate image, superseded by restate commit"
required: false
default: "ghcr.io/restatedev/restate:main"
type: string
serviceImage:
description: "service image, if provided it will skip building the image from sdk main branch"
required: false
default: ""
type: string

jobs:
sdk-test-suite:
if: github.repository_owner == 'restatedev'
runs-on: warp-ubuntu-latest-x64-4x
name: Features integration test (class-based API)
permissions:
contents: read
issues: read
checks: write
pull-requests: write
actions: read

steps:
- uses: actions/checkout@v4
with:
repository: restatedev/sdk-python

- name: Set up Docker containerd snapshotter
uses: docker/setup-docker-action@v4
with:
version: "v28.5.2"
set-host: true
daemon-config: |
{
"features": {
"containerd-snapshotter": true
}
}

### Download the Restate container image, if needed
# Setup restate snapshot if necessary
# Due to https://github.com/actions/upload-artifact/issues/53
# We must use download-artifact to get artifacts created during *this* workflow run, ie by workflow call
- name: Download restate snapshot from in-progress workflow
if: ${{ inputs.restateCommit != '' && github.event_name != 'workflow_dispatch' }}
uses: actions/download-artifact@v4
with:
name: restate.tar
# In the workflow dispatch case where the artifact was created in a previous run, we can download as normal
- name: Download restate snapshot from completed workflow
if: ${{ inputs.restateCommit != '' && github.event_name == 'workflow_dispatch' }}
uses: dawidd6/action-download-artifact@v3
with:
repo: restatedev/restate
workflow: ci.yml
commit: ${{ inputs.restateCommit }}
name: restate.tar
- name: Install restate snapshot
if: ${{ inputs.restateCommit != '' }}
run: |
output=$(docker load --input restate.tar | head -n 1)
docker tag "${output#*: }" "localhost/restatedev/restate-commit-download:latest"
docker image ls -a

# Either build the docker image from source
- name: Set up QEMU
if: ${{ inputs.serviceImage == '' }}
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
if: ${{ inputs.serviceImage == '' }}
uses: docker/setup-buildx-action@v3
with:
driver-opts: |
network=host
- name: Build Python test-services-cls image
if: ${{ inputs.serviceImage == '' }}
id: build
uses: docker/build-push-action@v6
with:
context: .
file: "test-services-cls/Dockerfile"
push: false
load: true
tags: restatedev/test-services-python-cls
cache-from: type=gha,url=http://127.0.0.1:49160/,version=1,scope=${{ github.workflow }}
cache-to: type=gha,url=http://127.0.0.1:49160/,mode=max,version=1,scope=${{ github.workflow }}

# Or use the provided one
- name: Pull test services image
if: ${{ inputs.serviceImage != '' }}
shell: bash
run: docker pull ${{ inputs.serviceImage }}

- name: Run test tool
uses: restatedev/sdk-test-suite@v3.4
with:
restateContainerImage: ${{ inputs.restateCommit != '' && 'localhost/restatedev/restate-commit-download:latest' || (inputs.restateImage != '' && inputs.restateImage || 'ghcr.io/restatedev/restate:main') }}
serviceContainerImage: ${{ inputs.serviceImage != '' && inputs.serviceImage || 'restatedev/test-services-python-cls' }}
exclusionsFile: "test-services-cls/exclusions.yaml"
testArtifactOutput: "sdk-python-cls-integration-test-report"
serviceContainerEnvFile: "test-services-cls/.env"
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
tmp/

# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
Expand Down
82 changes: 82 additions & 0 deletions etc/run-integration-tests.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
#!/usr/bin/env bash
set -euo pipefail

# Run the sdk-test-suite integration tests locally.
#
# Prerequisites:
# - Docker running
#
# Usage:
# ./etc/run-integration-tests.sh # test original test-services
# ./etc/run-integration-tests.sh --cls # test class-based test-services
# ./etc/run-integration-tests.sh --skip-build # reuse existing image
# ./etc/run-integration-tests.sh --cls --skip-build # combine flags

REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
SDK_TEST_SUITE_VERSION="v3.4"
JAR_URL="https://github.com/restatedev/sdk-test-suite/releases/download/${SDK_TEST_SUITE_VERSION}/restate-sdk-test-suite.jar"
JAR_PATH="${REPO_ROOT}/tmp/restate-sdk-test-suite.jar"
RESTATE_IMAGE="${RESTATE_CONTAINER_IMAGE:-ghcr.io/restatedev/restate:main}"
REPORT_DIR="${REPO_ROOT}/tmp/test-report"

SKIP_BUILD=false
USE_CLS=false
for arg in "$@"; do
case "$arg" in
--skip-build) SKIP_BUILD=true ;;
--cls) USE_CLS=true ;;
esac
done

if [ "$USE_CLS" = true ]; then
SERVICE_IMAGE="restatedev/test-services-python-cls"
DOCKERFILE="${REPO_ROOT}/test-services-cls/Dockerfile"
EXCLUSIONS="${REPO_ROOT}/test-services-cls/exclusions.yaml"
ENV_FILE="${REPO_ROOT}/test-services-cls/.env"
echo "==> Using class-based test-services (test-services-cls/)"
else
SERVICE_IMAGE="restatedev/test-services-python"
DOCKERFILE="${REPO_ROOT}/test-services/Dockerfile"
EXCLUSIONS="${REPO_ROOT}/test-services/exclusions.yaml"
ENV_FILE="${REPO_ROOT}/test-services/.env"
echo "==> Using original test-services (test-services/)"
fi

# 1. Build the test-services Docker image
if [ "$SKIP_BUILD" = false ]; then
echo "==> Building test-services Docker image..."
docker build -f "${DOCKERFILE}" -t "${SERVICE_IMAGE}" "${REPO_ROOT}"
fi

# 2. Download the test suite JAR (if not cached)
mkdir -p "$(dirname "$JAR_PATH")"
if [ ! -f "$JAR_PATH" ]; then
echo "==> Downloading sdk-test-suite ${SDK_TEST_SUITE_VERSION}..."
curl -fSL -o "$JAR_PATH" "$JAR_URL"
fi

# 3. Pull restate image
echo "==> Pulling Restate image: ${RESTATE_IMAGE}"
docker pull "${RESTATE_IMAGE}"

# 4. Run the test suite via Docker (no local Java needed)
echo "==> Running integration tests..."
rm -rf "${REPORT_DIR}"
mkdir -p "${REPORT_DIR}"

docker run --rm \
-v /var/run/docker.sock:/var/run/docker.sock \
-v "${JAR_PATH}:/opt/test-suite.jar:ro" \
-v "${EXCLUSIONS}:/opt/exclusions.yaml:ro" \
-v "${ENV_FILE}:/opt/service.env:ro" \
-v "${REPORT_DIR}:/opt/test-report" \
-e RESTATE_CONTAINER_IMAGE="${RESTATE_IMAGE}" \
--network host \
eclipse-temurin:21-jre \
java -jar /opt/test-suite.jar run \
--exclusions-file=/opt/exclusions.yaml \
--service-container-env-file=/opt/service.env \
--report-dir=/opt/test-report \
"${SERVICE_IMAGE}"

echo "==> Done. Test report: ${REPORT_DIR}"
Empty file added examples/README.md
Empty file.
142 changes: 142 additions & 0 deletions examples/class_based_greeter.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
"""
Class-based API example for the Restate Python SDK.

This example demonstrates the same services as the decorator-based examples,
but using the class-based API with @handler, @shared, and @main decorators.
"""

from datetime import timedelta

from pydantic import BaseModel

import restate
from restate.cls import Service, VirtualObject, Workflow, handler, shared, main, Restate


# ── Pydantic models ──


class GreetingRequest(BaseModel):
name: str
language: str = "en"


class GreetingResponse(BaseModel):
message: str
language: str


class Greeter(Service):
"""A simple stateless greeting service."""

@handler
async def greet(self, name: str) -> str:
return f"Hello {name}!"


class Counter(VirtualObject):
"""A stateful counter backed by durable state."""

@handler
async def increment(self, value: int) -> int:
n: int = await Restate.get("counter", type_hint=int) or 0
n += value
Restate.set("counter", n)
return n

@shared
async def count(self) -> int:
return await Restate.get("counter", type_hint=int) or 0


class PaymentWorkflow(Workflow):
"""A durable payment workflow with external verification."""

@main
async def pay(self, amount: int) -> str:
Restate.set("status", "processing")

async def charge():
return f"charged ${amount}"

receipt = await Restate.run("charge", charge)
Restate.set("status", "completed")
return receipt

@handler
async def status(self) -> str:
return await Restate.get("status", type_hint=str) or "unknown"


class OrderProcessor(Service):
"""Demonstrates type-safe RPC between services using fluent proxies."""

@handler
async def process(self, customer: str) -> str:
# Call a service handler — IDE knows .greet() takes str, returns str
greeting = await Greeter.call().greet(customer)

# Call a virtual object — IDE knows .increment() takes int, returns int
count = await Counter.call(customer).increment(1)

# Fire-and-forget send (returns SendHandle, not a coroutine)
Counter.send(customer).increment(1)

# Send with delay
Counter.send(customer, delay=timedelta(seconds=30)).increment(1)

# Call a workflow
receipt = await PaymentWorkflow.call(f"order-{count}").pay(100)

return f"{greeting} (visit #{count}, {receipt})"


class PydanticGreeter(Service):
"""Demonstrates Pydantic model serde with the class-based API."""

def __init__(self, name):
self.name = name

@handler
async def greet(self, req: GreetingRequest) -> GreetingResponse:
greetings = {"en": "Hello", "es": "Hola", "de": "Hallo"}
greeting = greetings.get(req.language, "Hello")

async def translate() -> GreetingResponse:
return GreetingResponse(message=f"{greeting} {req.name} from {self.name}", language=req.language)

return await Restate.run("translate", translate)


# ── Service contract without implementation ──
#
# Define the shape of a service (handlers + types) without providing an
# implementation. The proxy only needs class-level metadata created by
# __init_subclass__, so you can call a service that lives in another
# process — or is written in another language — just from its contract.


class ExternalInventory(VirtualObject, name="Inventory"):
"""Contract for an Inventory service whose implementation lives elsewhere."""

@handler
async def reserve(self, item_id: str) -> bool: ... # type: ignore[empty-body]

@handler
async def current_stock(self) -> int: ... # type: ignore[empty-body]


class Shop(Service):
"""Demonstrates calling a service defined only by its contract."""

@handler
async def buy(self, item_id: str) -> str:
# Full IDE autocomplete — reserve(str) -> bool, current_stock() -> int
ok = await ExternalInventory.call(item_id).reserve(item_id)
if not ok:
return "out of stock"
stock = await ExternalInventory.call(item_id).current_stock()
return f"reserved (remaining: {stock})"


app = restate.app([Greeter, Counter, PaymentWorkflow, OrderProcessor, PydanticGreeter("Restate"), Shop])
6 changes: 6 additions & 0 deletions examples/main.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
def main():
print("Hello from examples!")


if __name__ == "__main__":
main()
15 changes: 15 additions & 0 deletions examples/pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
[project]
name = "examples"
version = "0.1.0"
description = "Add your description here"
readme = "README.md"
requires-python = ">=3.12"
dependencies = [
"hypercorn>=0.18.0",
"pydantic>=2.12.5",
"restate-sdk",
"uvicorn>=0.38.0",
]

[tool.uv.sources]
restate-sdk = { path = ".." }
Loading
Loading