Your first service in Python¶
This tutorial writes a shopping cart in Python, runs it on your machine, tests it, and looks at it in
the local console. You need uv, Python 3.12, Docker, the Python SDK and the ankka-sidecar image from
Install the tools.
A Python service runs as its own process. ankka's runtime runs beside it as a sidecar: a container that owns everything stateful and distributed — the journal, sharding, projections, timers, HTTP and the agent loop. Your process owns the decisions: given this command and this state, what should happen. The two talk over gRPC on loopback, and the SDK hides that entirely. Services in other languages explains the model.
Create the project¶
uv init cart && cd cart
uv add "ankka[testkit]==0.5.0" # the version of the platform you will deploy to
uv add --dev pytest pytest-asyncio
An entity¶
An event sourced entity keeps its state as the fold of the events it persisted. Create cart.py:
# cart.py
from dataclasses import dataclass, replace
from ankka import DONE, Done, ErrorCode, EventSourcedEffect, EventSourcedEntity, ReadOnlyEffect, command, json_codec, query
@dataclass(frozen=True)
class LineItem:
productId: str
name: str
quantity: int
@dataclass(frozen=True)
class ShoppingCart:
cartId: str
items: list[LineItem]
checkedOut: bool
@dataclass(frozen=True)
class ItemAdded:
item: LineItem
@dataclass(frozen=True)
class CheckedOut:
pass
ShoppingCartEvent = ItemAdded | CheckedOut
class ShoppingCartEntity(EventSourcedEntity[ShoppingCart, ShoppingCartEvent]):
component_id = "shopping-cart"
state_codec = json_codec(ShoppingCart, "shopping-cart")
event_codec = json_codec(ShoppingCartEvent, "shopping-cart-event")
def empty_state(self) -> ShoppingCart:
return ShoppingCart(self.entity_id, [], False)
def apply_event(self, state: ShoppingCart, event: ShoppingCartEvent) -> ShoppingCart:
match event:
case ItemAdded(item):
return replace(state, items=[*state.items, item])
case CheckedOut():
return replace(state, checkedOut=True)
raise AssertionError(event)
@command("add-item")
def add_item(self, item: LineItem) -> EventSourcedEffect[ShoppingCart, ShoppingCartEvent, Done]:
if self.state.checkedOut:
return self.effects.error("cart is already checked out", ErrorCode.CONFLICT)
if item.quantity <= 0:
return self.effects.error(f"quantity must be greater than zero, was {item.quantity}")
return self.effects.persist(ItemAdded(item)).then_reply(lambda _: DONE)
@command("checkout")
def checkout(self) -> EventSourcedEffect[ShoppingCart, ShoppingCartEvent, ShoppingCart]:
if self.state.checkedOut:
return self.effects.error("cart is already checked out", ErrorCode.CONFLICT)
return self.effects.persist(CheckedOut()).then_reply_state()
@query("get-cart")
def get_cart(self) -> ReadOnlyEffect[ShoppingCart, ShoppingCartEvent, ShoppingCart]:
return self.effects.reply(self.state)
Four things in this file are true of every ankka component:
- A handler returns an effect.
persist(...).then_reply(...)is a description of what should happen. The handler performs no I/O, which is why it can be tested with nothing running. - The wire name is the decorator's argument.
"add-item"is what the platform stores and routes by. Renaming the methodadd_itemchanges nothing; renaming"add-item"is a protocol change. - A query cannot persist.
@queryhandlers must return aReadOnlyEffect, and registration refuses anything else. - Field names are the stored format. The codec writes
productIdexactly as declared. A Scala service reading the same journal expects the same names, so keep them as they are once data exists.
An endpoint¶
An endpoint is the service's HTTP edge. Create api.py:
# api.py
from ankka import Done, Endpoint, get, post
from ankka.client import Calls, ComponentClient
from cart import LineItem, ShoppingCart
class ShoppingCartEndpoint(Endpoint):
prefix = "/carts"
def __init__(self, client: ComponentClient) -> None:
self.client = client
def _cart(self, cart_id: str) -> Calls:
return self.client.with_metadata(self.request.metadata).for_event_sourced_entity("shopping-cart", cart_id)
@post("/{cartId}/items")
async def add_item(self, cartId: str, item: LineItem) -> Done:
return await self._cart(cartId).call("add-item").invoke(item, reply=Done)
@post("/{cartId}/checkout")
async def checkout(self, cartId: str) -> ShoppingCart:
return await self._cart(cartId).call("checkout").invoke(reply=ShoppingCart)
@get("/{cartId}")
async def get_cart(self, cartId: str) -> ShoppingCart:
return await self._cart(cartId).call("get-cart").invoke(reply=ShoppingCart)
Path parameters bind by name from the route template. One further typed parameter is the request body,
and the return value is encoded by its type. Your process never binds an HTTP port: the sidecar serves
the routes you declared, applies the endpoint's access control list, and forwards each request. This
endpoint does not set acl, and the default allows every caller. That is fine on your machine; decide
it deliberately before the service is exposed, as HTTP endpoints shows. Passing self.request.metadata on to the entity call
is what makes that call appear as a child of the request in a trace.
Run it¶
Register both components and listen. Create main.py:
# main.py
import asyncio
from ankka import Ankka
from api import ShoppingCartEndpoint
from cart import ShoppingCartEntity
asyncio.run(Ankka.service().register(ShoppingCartEntity).register(ShoppingCartEndpoint).listen())
Registration is explicit: a component that is not registered does not exist. listen() serves the
protocol on port 9010 on loopback, where the sidecar finds it.
Start Postgres and the sidecar from the root of the ankka repository, then your process:
docker compose --profile polyglot up -d # in the ankka repository: Postgres and the sidecar
uv run python main.py # in your project: your process, on 9010
In another terminal:
curl -X POST localhost:9000/carts/c1/items -H 'content-type: application/json' \
-d '{"productId":"p1","name":"Pen","quantity":2}'
curl localhost:9000/carts/c1
# {"cartId":"c1","items":[{"productId":"p1","name":"Pen","quantity":2}],"checkedOut":false}
Stop both and start both again, then read the cart: it is still there. The journal belongs to the sidecar and lives in Postgres, under the same records a Scala service writes.
Test it without the sidecar¶
The unit testkit runs a component with no sidecar and no network. Create test_cart.py:
# test_cart.py
from ankka import ErrorCode
from ankka.testkit import EventSourcedTestKit
from cart import ItemAdded, LineItem, ShoppingCartEntity
PEN = LineItem("p1", "Pen", 2)
def test_adding_an_item_persists_one_event() -> None:
kit = EventSourcedTestKit.of(ShoppingCartEntity, "c1")
assert kit.call("add-item", PEN).events == (ItemAdded(PEN),)
assert kit.call("get-cart").reply.items == [PEN]
def test_a_refused_command_persists_nothing() -> None:
kit = EventSourcedTestKit.of(ShoppingCartEntity, "c1")
result = kit.call("add-item", LineItem("p1", "Pen", 0))
assert result.error is not None and result.error.code == ErrorCode.BAD_REQUEST
assert result.events == ()
uv run pytest -q
These take milliseconds. Inputs, events, state and replies still round-trip through your codecs, so a type the codec cannot encode fails here rather than on first deployment.
Test it through the real sidecar¶
The integration testkit starts Postgres and the sidecar image in Docker, serves your components to it, and gives you an HTTP client for your routes:
# test_integration.py
from ankka import Ankka
from ankka.testkit.integration import AnkkaTestKit
from api import ShoppingCartEndpoint
from cart import ShoppingCartEntity
async def test_a_cart_survives_a_restart() -> None:
service = Ankka.service().register(ShoppingCartEntity).register(ShoppingCartEndpoint)
async with await AnkkaTestKit.start(service) as kit:
await kit.http.post("/carts/c1/items", json={"productId": "p1", "name": "Pen", "quantity": 2})
await kit.restart() # a new sidecar, the same database
assert (await kit.http.get("/carts/c1")).json()["items"][0]["name"] == "Pen"
The testkit needs the testkit extra (testcontainers and httpx) and asyncio_mode = "auto" in your
pytest configuration. restart() replaces the sidecar and keeps the database, which is how a test proves
the state is durable rather than cached.
Watch it in the local console¶
ankka local console # http://localhost:9889
The sidecar is a local ankka service like any other, so the console lists it: its components, a form
per route, and a trace of each request. The trace of POST /{cartId}/items shows the endpoint and the
shopping-cart#add-item call beneath it. The console reads a cart's state through the entity's declared
query get-cart, and refuses to run a command. The local console covers
the rest.
Next¶
Deploy to a local platform. A Python service is deployed exactly as a Scala one is,
with two extra fields in its descriptor: "hosting": "process" and the protocol version its SDK speaks.