Python SDK¶
The Python SDK is the package ankka, for Python 3.12 and later. A Python service is a process that the
ankka sidecar hosts: the sidecar owns the journal, sharding, projections, timers, HTTP and the agent loop,
and your classes decide what each command does. This page lists what each component kind is made of.
Services in other languages explains the model.
Installing¶
The SDK is on PyPI as ankka. Every ankka release publishes the SDK
at the same version, so pin the version of the platform you deploy to:
uv add "ankka==0.5.0" # the SDK
uv add "ankka[testkit]==0.5.0" # with the integration testkit's dependencies
pip install ankka==0.5.0 does the same for a project that does not use uv. To work against an unreleased
SDK, install it from a checkout of the ankka repository instead, by path (uv add --editable
/path/to/ankka/sdks/python), after generating its protocol stubs as described under
Developing the SDK.
It depends on grpcio and protobuf. The testkit extra adds testcontainers and httpx, which the
integration testkit needs to start Postgres and the sidecar image. Docker is needed for integration tests
and for running the sidecar locally; a JVM is not.
The shape every component shares¶
A component is a class that extends the kind's base class and declares, as class attributes, its
component_id and its codecs. Handlers are methods decorated with their wire name:
class ShoppingCartEntity(EventSourcedEntity[ShoppingCart, ShoppingCartEvent]):
component_id = "shopping-cart"
state_codec = json_codec(ShoppingCart, "shopping-cart")
event_codec = json_codec(ShoppingCartEvent, "shopping-cart-event")
@command("add-item")
def add_item(self, item: LineItem) -> EventSourcedEffect[ShoppingCart, ShoppingCartEvent, Done]: ...
@query("get-cart")
def get_cart(self) -> ReadOnlyEffect[ShoppingCart, ShoppingCartEvent, ShoppingCart]: ...
A handler's argument and reply types are read from its annotations and encoded with the SDK's default
codec: dataclasses and unions of dataclasses as JSON, primitives as text. json_codec(cls, "manifest")
names the codec for a state, event or row type explicitly. A mistake in a class, such as a query that does
not return a read-only effect, raises RegistrationError when the class is defined or registered.
Field names are the wire contract. The JSON the SDK writes is the JSON the Scala SDK writes, so use the same
field names, productId rather than product_id, when a Scala service shares the data.
Event sourced entity¶
| Part | API |
|---|---|
| Base class | EventSourcedEntity[S, E] |
| Class attributes | component_id, state_codec, event_codec |
| Must define | empty_state(self) -> S, apply_event(self, state, event) -> S |
| Decorators | @command("name"), @query("name") |
| In a handler | self.state, self.entity_id, self.context (a CommandContext), self.effects |
| Effects | persist(e, *more), persist_all(events), delete_entity(), then .then_reply(lambda s: r), .then_reply_state(), .then_no_reply(), .delete_entity(), .expire_after(timedelta); reply(r), error(msg, code), no_reply() |
| Types | A command returns EventSourcedEffect[S, E, R]; a query returns ReadOnlyEffect[S, E, R] |
Key value entity¶
| Part | API |
|---|---|
| Base class | ankka.key_value_entity.KeyValueEntity[S] |
| Class attributes | component_id, state_codec |
| Must define | empty_state(self) -> S |
| Decorators | @command("name"), @query("name") |
| In a handler | self.state, self.entity_id, self.context, self.effects |
| Effects | update_state(s), delete_entity(), then .then_reply(lambda s: r), .then_reply_state(), .then_no_reply(), .expire_after(timedelta); reply(r), error(msg, code), no_reply() |
See Key value entities.
View¶
| Part | API |
|---|---|
| Base class | ankka.view.View[Src, Row] |
| Class attributes | component_id, source (an entity class) or topic (a topic name), event_codec, row_codec |
| Must define | on_change(self, event) -> ViewEffect |
| May override | on_delete(self) -> ViewEffect, which deletes the row by default |
| In a handler | self.row (the current row or None), self.metadata (subject, sequence_number), self.effects |
| Effects | update_row(row), delete_row(), ignore() |
| Querying | client.views.get(view_id, key, RowType), client.views.all(view_id, RowType) |
See Views.
Consumer¶
| Part | API |
|---|---|
| Base class | ankka.consumer.Consumer[Src, Out] |
| Class attributes | component_id, source or topic, message_codec; to publish, produces_to and out_codec |
| Must define | async on_message(self, message) -> ConsumerEffect |
| May override | on_delete(self), which ignores by default |
| In a handler | self.metadata, self.client, self.effects |
| Effects | produce(out, metadata=None), done(), ignore() |
Delivery is at least once. A consumer that produces needs ANKKA_KAFKA_BOOTSTRAP_SERVERS on the sidecar.
See Consumers.
Workflow¶
| Part | API |
|---|---|
| Base class | ankka.workflow.Workflow[S] |
| Class attributes | component_id, state_codec, optionally settings |
| Must define | empty_state(self) -> S |
| Decorators | @command("name"), @query("name"), @step("name") from ankka.workflow |
| In a handler | self.state, self.entity_id, self.context (with client), self.effects, self.step_effects |
| Command effects | update_state(s), transition_to(step, input=None), then .then_transition_to(step, input), .then_reply(lambda s: r), .then_reply_state(), .then_no_reply(); reply(r), error(msg, code) |
| Step effects | update_state(s) then, or directly: transition_to(step, input), pause(after=None, on_timeout=None), end(), fail(msg, code) |
| Settings | WorkflowSettings(timeout, default_step_timeout, default_recovery, steps={name: StepSettings(timeout, recovery)}) |
| Recovery | Recovery(max_retries=n, failover_to="step") |
Steps are async and may call other components. A step that raises is retried as its recovery says, then
failed over. See Workflows.
Timed action and timers¶
| Part | API |
|---|---|
| Base class | ankka.timed_action.TimedAction |
| Class attributes | component_id |
| Decorators | @action("name") from ankka.timed_action |
| In a handler | self.metadata (the timer's name and attempt count), self.client, self.effects |
| Effects | done(), fail(msg, code) |
| Scheduling | await client.timers.schedule(timer_id, timedelta, component_id, action, input), await client.timers.cancel(timer_id) |
Scheduling twice under one id replaces the earlier timer. See Timers.
Agent¶
| Part | API |
|---|---|
| Base class | ankka.agent.Agent |
| Class attributes | component_id, tools ({name: Tool(description, function, InputDataclass)}), guardrails ({name: Guardrail(check)}), optionally role, max_tool_call_steps |
| Decorators | @command("name"), @stream("name") from ankka.agent |
| In a handler | self.session_id, self.metadata, self.client, self.effects |
| Effects | system_message(t), user_message(t), model(name), then .with_model(name), .with_context(t), .memory(bool), .tools(*names), .guardrails(*names), .then_reply(), .then_reply_json(); error(msg, code) |
A handler returns a plan; the sidecar runs the model loop, calls tools back in the process with the model's
arguments, and checks guardrails. A guardrail's check takes the stage ("input" or "output") and the text,
and returns None to pass or a reason to block. See Agents.
HTTP endpoint¶
| Part | API |
|---|---|
| Base class | Endpoint |
| Class attributes | prefix, acl (required: Acl.ALLOW_ALL, Acl.DENY_ALL or Acl.AUTHENTICATED) |
| Decorators | @get, @post, @put, @patch, @delete, @sse, each with a path template and an optional acl= for that route alone |
| Handlers | async methods; path parameters bind by name, one further typed parameter is the body, the return value is encoded by its type |
| In a handler | self.request: query_param, query_params, header, principal, metadata |
| Errors | raise HttpProblem(status, message); a CommandError from a call answers with its code's status |
The constructor receives the component client when it takes one. The process never binds an HTTP port: the
sidecar serves the routes and forwards each request. acl is required, as it is in the Scala SDK: a class
that omits it raises RegistrationError when it is defined, naming the class, rather than serving requests
under a posture nobody chose. A route decorator's acl= replaces the endpoint's for that route; a decorator
that omits it leaves the endpoint's in force. See HTTP endpoints.
Calling components¶
cart = client.with_metadata(self.request.metadata).for_event_sourced_entity("shopping-cart", cart_id)
await cart.call("add-item").invoke(item, reply=Done)
state = await cart.call("get-cart").invoke(reply=ShoppingCart)
| Target | Call |
|---|---|
| Event sourced entity | client.for_event_sourced_entity(component_id, entity_id) |
| Key value entity | client.for_key_value_entity(component_id, entity_id) |
| Workflow | client.for_workflow(component_id, workflow_id) |
| Agent | client.for_agent(component_id, session_id) |
.call(name) gives an invocation: await invocation.invoke(input, reply=Type), or
async for token in invocation.stream(input) for a streaming agent handler. A refusal raises
ankka.client.CommandError, whose error holds the message and code. Passing a request's metadata on
makes the call a child span of the request's trace. See Calling components.
Running a service¶
import asyncio
from ankka import Ankka
asyncio.run(Ankka.service().register(ShoppingCartEntity).register(ShoppingCartEndpoint).listen())
listen() serves the protocol on port 9010, or ANKKA_PROCESS_PORT, and waits for the sidecar to connect.
Locally, run the sidecar with docker compose --profile polyglot up -d from the ankka repository.
Testing¶
| Kit | Module | Drives |
|---|---|---|
EventSourcedTestKit.of(Cls, id) |
ankka.testkit |
One entity; call(name, input) returns events, state and reply. |
KeyValueTestKit.of(Cls, id) |
ankka.testkit |
One key value entity. |
WorkflowTestKit.of(Cls, id) |
ankka.testkit |
One workflow: call, run_step, run_until_end. |
ViewTestKit.of(Cls) |
ankka.testkit |
A view's on_change and on_delete, and the resulting rows. |
ConsumerTestKit.of(Cls) |
ankka.testkit |
A consumer's on_message and on_delete. |
TimedActionTestKit.of(Cls) |
ankka.testkit |
A timed action's handlers. |
AgentTestKit.of(Cls, session_id, model=ScriptedModel()) |
ankka.testkit |
An agent's plan, tools and guardrails, against a scripted model. |
EndpointTestKit.of(Cls, …) |
ankka.testkit |
An endpoint's routes, with no sidecar. |
AnkkaTestKit.start(builder) |
ankka.testkit.integration |
The whole service through the real sidecar image and a throwaway Postgres. restart() starts a new sidecar on the same database. |
Unit testkits need nothing running and still round-trip every value through its codec. See Testing.
Developing the SDK¶
From sdks/python in the ankka repository:
uv sync # install, with the development dependencies
uv run python scripts/proto.py # copy the protocol in and regenerate the gRPC stubs
uv run pytest # unit testkits, the encoding fixtures, the servicer
uv run pytest -m slow # through the real sidecar and Postgres; needs Docker
uv run mypy && uv run mypy examples # strict type checking
uv run conformance # serve the reference service and run the platform's conformance suite
uv run python -m examples.shopping_cart.main # the sample, beside `docker compose --profile polyglot up`
The SDK carries a copy of the protocol so it can be built on its own; scripts/proto.py refreshes it. The
conformance suite needs sbt and runs the platform's suite against the SDK's reference service. See
Adding a language SDK.