diff --git a/.github/workflows/pre-release.yml b/.github/workflows/pre-release.yml index 073f6b5a..9a344215 100644 --- a/.github/workflows/pre-release.yml +++ b/.github/workflows/pre-release.yml @@ -19,6 +19,10 @@ on: description: "sdk-go version (WITH the prepending v). Leave empty if you do not want to update it." required: false type: string + sdkPythonVersion: + description: 'sdk-python version (without prepending v). Leave empty if you do not want to update it.' + required: false + type: string cdkVersion: description: "cdk version (without prepending v). Leave empty if you do not want to update it." required: false @@ -71,6 +75,20 @@ jobs: if: github.event.inputs.sdkJavaVersion != '' run: ./.tools/run_jvm_tests.sh + # Bump Python SDK + - uses: actions/checkout@v3 + if: github.event.inputs.sdkPythonVersion != '' + - uses: actions/setup-python@v5 + if: github.event.inputs.sdkPythonVersion != '' + with: + python-version: "3.12" + - name: Bump Python SDK + if: github.event.inputs.sdkPythonVersion != '' + run: ./.tools/update_python_examples.sh ${{ inputs.sdkPythonVersion }} + - name: Run Python tests + if: github.event.inputs.sdkPythonVersion != '' + run: ./.tools/run_python_tests.sh + # Bump Go SDK - uses: actions/setup-go@v5 if: github.event.inputs.sdkGoVersion != '' @@ -93,5 +111,6 @@ jobs: **/package-lock.json **/build.gradle.kts **/pom.xml + **/requirements.txt **/go.mod **/go.sum diff --git a/.tools/run_python_tests.sh b/.tools/run_python_tests.sh index c0059265..c5cb4b45 100755 --- a/.tools/run_python_tests.sh +++ b/.tools/run_python_tests.sh @@ -14,4 +14,5 @@ function python_mypi_lint() { deactivate } -pushd $PROJECT_ROOT/templates/python && python_mypi_lint && popd || exit \ No newline at end of file +pushd $PROJECT_ROOT/templates/python && python_mypi_lint && popd || exit +pushd $PROJECT_ROOT/patterns-use-cases/ticket-reservation/ticket-reservation-python && python_mypi_lint && popd || exit \ No newline at end of file diff --git a/.tools/update_python_examples.sh b/.tools/update_python_examples.sh new file mode 100755 index 00000000..f93cd204 --- /dev/null +++ b/.tools/update_python_examples.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env bash + +set -eufx -o pipefail + +NEW_VERSION=$1 +SELF_PATH=${BASH_SOURCE[0]:-"$(command -v -- "$0")"} +PROJECT_ROOT="$(dirname "$SELF_PATH")/.." + +function search_and_replace_version() { + echo "upgrading Python version of $1 to $NEW_VERSION" + sed -i 's/restate_sdk==[0-9A-Za-z.-]*/restate_sdk=='"$NEW_VERSION"'/' "$1/requirements.txt" +} + +search_and_replace_version $PROJECT_ROOT/templates/python +search_and_replace_version $PROJECT_ROOT/patterns-use-cases/ticket-reservation/ticket-reservation-python diff --git a/README.md b/README.md index 34703ae9..e240eebc 100644 --- a/README.md +++ b/README.md @@ -69,6 +69,14 @@ challenges. | Use Cases | [Sagas](patterns-use-cases/sagas/sagas-kotlin) | | End-to-End | [Food Ordering App](end-to-end-applications/kotlin/food-ordering) | + +### Python + +| Type | Name / Link | +|-----------|---------------------------------------------------------------------------------------| +| Templates | [Python Template](templates/python) | +| Use Cases | [Ticket reservation](patterns-use-cases/ticket-reservation/ticket-reservation-python) | + ## Joining the community If you want to join the Restate community in order to stay up to date, then please join our [Discord](https://discord.gg/skW3AZ6uGd). @@ -129,6 +137,6 @@ git tag -m "Examples v0.9.1" v0.9.1 git push origin v0.9.1 ``` -This triggers a workflow that [creates a draft release](https://github.com/restatedev/examples/releases) on Github, which you need to approve to finalize it. +This triggers a workflow that [creates a draft release](https://github.com/restatedev/examples/releases) on GitHub, which you need to approve to finalize it. Please update the version tag referenced on the [Tour of Restate](https://github.com/restatedev/documentation/blob/main/docs/tour.mdx) documentation page. diff --git a/patterns-use-cases/ticket-reservation/ticket-reservation-python/.gitignore b/patterns-use-cases/ticket-reservation/ticket-reservation-python/.gitignore new file mode 100644 index 00000000..69e9b277 --- /dev/null +++ b/patterns-use-cases/ticket-reservation/ticket-reservation-python/.gitignore @@ -0,0 +1,3 @@ +venv +.venv +__pycache__/ diff --git a/patterns-use-cases/ticket-reservation/ticket-reservation-python/README.md b/patterns-use-cases/ticket-reservation/ticket-reservation-python/README.md new file mode 100644 index 00000000..3c7b6cf1 --- /dev/null +++ b/patterns-use-cases/ticket-reservation/ticket-reservation-python/README.md @@ -0,0 +1,55 @@ +# Restate Example: Ticket reservation system Python + +This example shows a subset of a ticket booking system. + +Restate is a system for easily building resilient applications using **distributed durable building blocks**. + +❓ Learn more about Restate from the [Restate documentation](https://docs.restate.dev). + +## Running the example + +To set up the example, use the following sequence of commands. + +Setup the virtual env: + +```shell +python3 -m venv .venv +source .venv/bin/activate +``` + +Install the requirements: + +```shell +pip install -r requirements.txt +``` + +Start the app as follows: + +```shell +python3 -m hypercorn example/app:app +``` + +Start the Restate Server ([other options here](https://docs.restate.dev/develop/local_dev)): + +```shell +restate-server +``` + +Register the service: + +```shell +restate dp register http://localhost:8000 +``` + +Then add a ticket to Mary's cart: + +```shell +curl localhost:8080/cart/Mary/add_ticket -H 'content-type: application/json' -d '"seat2B"' +``` + +Let Mary buy the ticket via: +```shell +curl -X POST localhost:8080/cart/Mary/checkout +``` + +That's it! We managed to run the example, add a ticket to the user session cart, and buy it! diff --git a/patterns-use-cases/ticket-reservation/ticket-reservation-python/example/__init__.py b/patterns-use-cases/ticket-reservation/ticket-reservation-python/example/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/patterns-use-cases/ticket-reservation/ticket-reservation-python/example/app.py b/patterns-use-cases/ticket-reservation/ticket-reservation-python/example/app.py new file mode 100644 index 00000000..54a92f78 --- /dev/null +++ b/patterns-use-cases/ticket-reservation/ticket-reservation-python/example/app.py @@ -0,0 +1,7 @@ +import restate + +from example.cart_object import cart +from example.checkout_service import checkout +from example.ticket_object import ticket + +app = restate.app(services=[cart, checkout, ticket]) diff --git a/patterns-use-cases/ticket-reservation/ticket-reservation-python/example/cart_object.py b/patterns-use-cases/ticket-reservation/ticket-reservation-python/example/cart_object.py new file mode 100644 index 00000000..cce73751 --- /dev/null +++ b/patterns-use-cases/ticket-reservation/ticket-reservation-python/example/cart_object.py @@ -0,0 +1,58 @@ +from datetime import timedelta + +from restate.context import ObjectContext +from restate.object import VirtualObject + +from example.checkout_service import handle +from example.ticket_object import reserve, mark_as_sold, unreserve + +cart = VirtualObject("cart") + + +@cart.handler() +async def add_ticket(ctx: ObjectContext, ticket_id: str) -> bool: + reserved = await ctx.object_call(reserve, key=ticket_id, arg=None) + + if reserved: + tickets = await ctx.get("tickets") or [] + tickets.append(ticket_id) + ctx.set("tickets", tickets) + + ctx.object_send(expire_ticket, key=ctx.key(), arg=ticket_id, send_delay=timedelta(minutes=15)) + + return reserved + + +@cart.handler() +async def checkout(ctx: ObjectContext) -> bool: + tickets = await ctx.get("tickets") or [] + + if len(tickets) == 0: + return False + + success = await ctx.service_call(checkout_handle, arg={'user_id': ctx.key(), + 'tickets': tickets}) + + if success: + for ticket in tickets: + ctx.object_send(mark_as_sold, key=ticket, arg=None) + + ctx.clear("tickets") + + return success + + +@cart.handler() +async def expire_ticket(ctx: ObjectContext, ticket_id: str): + tickets = await ctx.get("tickets") or [] + + try: + ticket_index = tickets.index(ticket_id) + except ValueError: + ticket_index = -1 + + if ticket_index != -1: + tickets.pop(ticket_index) + ctx.set("tickets", tickets) + + ctx.object_send(unreserve, key=ticket_id, arg=None) diff --git a/patterns-use-cases/ticket-reservation/ticket-reservation-python/example/checkout_service.py b/patterns-use-cases/ticket-reservation/ticket-reservation-python/example/checkout_service.py new file mode 100644 index 00000000..95c01c1f --- /dev/null +++ b/patterns-use-cases/ticket-reservation/ticket-reservation-python/example/checkout_service.py @@ -0,0 +1,36 @@ +import uuid +from typing import TypedDict, List +from restate.context import ObjectContext, Serde +from restate.service import Service + +from example.utils.email_client import EmailClient +from example.utils.payment_client import PaymentClient + + +class Order(TypedDict): + user_id: str + tickets: List[str] + + +payment_client = PaymentClient() +email_client = EmailClient() + +checkout = Service("checkout") + + +@checkout.handler() +async def handle(ctx: ObjectContext, order: Order) -> bool: + total_price = len(order['tickets']) * 40 + + idempotency_key = await ctx.run("idempotency_key", lambda: str(uuid.uuid4())) + + async def pay(): + return await payment_client.call(idempotency_key, total_price) + success = await ctx.run("payment", pay) + + if success: + await ctx.run("send_success_email", lambda: email_client.notify_user_of_payment_success(order['user_id'])) + else: + await ctx.run("send_failure_email", lambda: email_client.notify_user_of_payment_failure(order['user_id'])) + + return success diff --git a/patterns-use-cases/ticket-reservation/ticket-reservation-python/example/ticket_object.py b/patterns-use-cases/ticket-reservation/ticket-reservation-python/example/ticket_object.py new file mode 100644 index 00000000..ec40c2da --- /dev/null +++ b/patterns-use-cases/ticket-reservation/ticket-reservation-python/example/ticket_object.py @@ -0,0 +1,32 @@ +from restate.context import ObjectContext +from restate.object import VirtualObject + + +ticket = VirtualObject("ticket") + + +@ticket.handler() +async def reserve(ctx: ObjectContext) -> bool: + status = await ctx.get("status") or "AVAILABLE" + + if status == "AVAILABLE": + ctx.set("status", "RESERVED") + return True + else: + return False + + +@ticket.handler() +async def unreserve(ctx: ObjectContext): + status = await ctx.get("status") or "AVAILABLE" + + if status != "SOLD": + ctx.clear("status") + + +@ticket.handler() +async def mark_as_sold(ctx: ObjectContext): + status = await ctx.get("status") or "AVAILABLE" + + if status == "RESERVED": + ctx.set("status", "SOLD") diff --git a/patterns-use-cases/ticket-reservation/ticket-reservation-python/example/utils/__init__.py b/patterns-use-cases/ticket-reservation/ticket-reservation-python/example/utils/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/patterns-use-cases/ticket-reservation/ticket-reservation-python/example/utils/email_client.py b/patterns-use-cases/ticket-reservation/ticket-reservation-python/example/utils/email_client.py new file mode 100644 index 00000000..201d9b0d --- /dev/null +++ b/patterns-use-cases/ticket-reservation/ticket-reservation-python/example/utils/email_client.py @@ -0,0 +1,14 @@ +class EmailClient: + + def __init__(self): + self.i = 0 + + def notify_user_of_payment_success(self, user_id: str): + print(f"Notifying user {user_id} of payment success") + # send the email + return True + + def notify_user_of_payment_failure(self, user_id: str): + print(f"Notifying user {user_id} of payment failure") + # send the email + return True diff --git a/patterns-use-cases/ticket-reservation/ticket-reservation-python/example/utils/payment_client.py b/patterns-use-cases/ticket-reservation/ticket-reservation-python/example/utils/payment_client.py new file mode 100644 index 00000000..2c093703 --- /dev/null +++ b/patterns-use-cases/ticket-reservation/ticket-reservation-python/example/utils/payment_client.py @@ -0,0 +1,19 @@ +class PaymentClient: + + def __init__(self): + self.i = 0 + + async def call(self, idempotency_key: str, amount: float) -> bool: + print(f"Payment call succeeded for idempotency key {idempotency_key} and amount {amount}") + # do the call + return True + + async def failing_call(self, idempotency_key: str, amount: float) -> bool: + if self.i >= 2: + print(f"Payment call succeeded for idempotency key {idempotency_key} and amount {amount}") + i = 0 + return True + else: + print(f"Payment call failed for idempotency key {idempotency_key} and amount {amount}. Retrying...") + self.i += 1 + raise Exception("Payment call failed") \ No newline at end of file diff --git a/patterns-use-cases/ticket-reservation/ticket-reservation-python/requirements.txt b/patterns-use-cases/ticket-reservation/ticket-reservation-python/requirements.txt new file mode 100644 index 00000000..f0e15905 --- /dev/null +++ b/patterns-use-cases/ticket-reservation/ticket-reservation-python/requirements.txt @@ -0,0 +1,2 @@ +restate_sdk==0.1.1 +hypercorn \ No newline at end of file diff --git a/templates/python/requirements.txt b/templates/python/requirements.txt index 949de4a6..bdc12829 100644 --- a/templates/python/requirements.txt +++ b/templates/python/requirements.txt @@ -1,2 +1,2 @@ hypercorn -restate_sdk \ No newline at end of file +restate_sdk==0.1.1 \ No newline at end of file