ankka 0.4.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (134) hide show
  1. ankka-0.4.0/.gitignore +33 -0
  2. ankka-0.4.0/PKG-INFO +50 -0
  3. ankka-0.4.0/README.md +24 -0
  4. ankka-0.4.0/examples/__init__.py +0 -0
  5. ankka-0.4.0/examples/shopping_cart/Dockerfile +11 -0
  6. ankka-0.4.0/examples/shopping_cart/__init__.py +2 -0
  7. ankka-0.4.0/examples/shopping_cart/assistant.py +49 -0
  8. ankka-0.4.0/examples/shopping_cart/cart_rows.py +51 -0
  9. ankka-0.4.0/examples/shopping_cart/checkout_log.py +33 -0
  10. ankka-0.4.0/examples/shopping_cart/checkout_notifier.py +29 -0
  11. ankka-0.4.0/examples/shopping_cart/checkout_workflow.py +81 -0
  12. ankka-0.4.0/examples/shopping_cart/conformance.py +315 -0
  13. ankka-0.4.0/examples/shopping_cart/domain.py +68 -0
  14. ankka-0.4.0/examples/shopping_cart/endpoint.py +94 -0
  15. ankka-0.4.0/examples/shopping_cart/entity.py +59 -0
  16. ankka-0.4.0/examples/shopping_cart/main.py +40 -0
  17. ankka-0.4.0/examples/shopping_cart/service.json +8 -0
  18. ankka-0.4.0/examples/shopping_cart/test_cart.py +245 -0
  19. ankka-0.4.0/proto/ENCODING.md +78 -0
  20. ankka-0.4.0/proto/README.md +64 -0
  21. ankka-0.4.0/proto/fixtures/binary-bytes.json +6 -0
  22. ankka-0.4.0/proto/fixtures/binary-done.json +6 -0
  23. ankka-0.4.0/proto/fixtures/binary-option-none.json +6 -0
  24. ankka-0.4.0/proto/fixtures/binary-option-some.json +6 -0
  25. ankka-0.4.0/proto/fixtures/binary-unit.json +6 -0
  26. ankka-0.4.0/proto/fixtures/enum-as-field.json +6 -0
  27. ankka-0.4.0/proto/fixtures/numbers.json +6 -0
  28. ankka-0.4.0/proto/fixtures/record-empty-collection.json +6 -0
  29. ankka-0.4.0/proto/fixtures/record-instants-and-options.json +6 -0
  30. ankka-0.4.0/proto/fixtures/record-nested-and-map.json +6 -0
  31. ankka-0.4.0/proto/fixtures/record-with-collection.json +6 -0
  32. ankka-0.4.0/proto/fixtures/recursive.json +6 -0
  33. ankka-0.4.0/proto/fixtures/sum-type-fieldless.json +6 -0
  34. ankka-0.4.0/proto/fixtures/sum-type-optional-absent.json +6 -0
  35. ankka-0.4.0/proto/fixtures/sum-type-string-field.json +6 -0
  36. ankka-0.4.0/proto/fixtures/sum-type-with-fields.json +6 -0
  37. ankka-0.4.0/proto/fixtures/sum-type-with-instant.json +6 -0
  38. ankka-0.4.0/proto/fixtures/text-boolean.json +6 -0
  39. ankka-0.4.0/proto/fixtures/text-double.json +6 -0
  40. ankka-0.4.0/proto/fixtures/text-duration-millis.json +6 -0
  41. ankka-0.4.0/proto/fixtures/text-int.json +6 -0
  42. ankka-0.4.0/proto/fixtures/text-long.json +6 -0
  43. ankka-0.4.0/proto/fixtures/text-string.json +6 -0
  44. ankka-0.4.0/proto/src/main/protobuf/ankka/protocol/v1/agent.proto +53 -0
  45. ankka-0.4.0/proto/src/main/protobuf/ankka/protocol/v1/client.proto +43 -0
  46. ankka-0.4.0/proto/src/main/protobuf/ankka/protocol/v1/consumer.proto +19 -0
  47. ankka-0.4.0/proto/src/main/protobuf/ankka/protocol/v1/discovery.proto +101 -0
  48. ankka-0.4.0/proto/src/main/protobuf/ankka/protocol/v1/endpoint.proto +46 -0
  49. ankka-0.4.0/proto/src/main/protobuf/ankka/protocol/v1/event_sourced.proto +38 -0
  50. ankka-0.4.0/proto/src/main/protobuf/ankka/protocol/v1/key_value.proto +24 -0
  51. ankka-0.4.0/proto/src/main/protobuf/ankka/protocol/v1/payload.proto +53 -0
  52. ankka-0.4.0/proto/src/main/protobuf/ankka/protocol/v1/timed_action.proto +11 -0
  53. ankka-0.4.0/proto/src/main/protobuf/ankka/protocol/v1/view.proto +17 -0
  54. ankka-0.4.0/proto/src/main/protobuf/ankka/protocol/v1/workflow.proto +39 -0
  55. ankka-0.4.0/pyproject.toml +88 -0
  56. ankka-0.4.0/scripts/conformance.py +10 -0
  57. ankka-0.4.0/scripts/proto.py +73 -0
  58. ankka-0.4.0/src/ankka/__init__.py +18 -0
  59. ankka-0.4.0/src/ankka/_conformance.py +56 -0
  60. ankka-0.4.0/src/ankka/_proto/__init__.py +0 -0
  61. ankka-0.4.0/src/ankka/_proto/ankka/__init__.py +0 -0
  62. ankka-0.4.0/src/ankka/_proto/ankka/protocol/__init__.py +0 -0
  63. ankka-0.4.0/src/ankka/_proto/ankka/protocol/v1/__init__.py +0 -0
  64. ankka-0.4.0/src/ankka/_proto/ankka/protocol/v1/agent_pb2.py +59 -0
  65. ankka-0.4.0/src/ankka/_proto/ankka/protocol/v1/agent_pb2.pyi +118 -0
  66. ankka-0.4.0/src/ankka/_proto/ankka/protocol/v1/agent_pb2_grpc.py +183 -0
  67. ankka-0.4.0/src/ankka/_proto/ankka/protocol/v1/client_pb2.py +52 -0
  68. ankka-0.4.0/src/ankka/_proto/ankka/protocol/v1/client_pb2.pyi +84 -0
  69. ankka-0.4.0/src/ankka/_proto/ankka/protocol/v1/client_pb2_grpc.py +270 -0
  70. ankka-0.4.0/src/ankka/_proto/ankka/protocol/v1/consumer_pb2.py +43 -0
  71. ankka-0.4.0/src/ankka/_proto/ankka/protocol/v1/consumer_pb2.pyi +36 -0
  72. ankka-0.4.0/src/ankka/_proto/ankka/protocol/v1/consumer_pb2_grpc.py +97 -0
  73. ankka-0.4.0/src/ankka/_proto/ankka/protocol/v1/discovery_pb2.py +83 -0
  74. ankka-0.4.0/src/ankka/_proto/ankka/protocol/v1/discovery_pb2.pyi +232 -0
  75. ankka-0.4.0/src/ankka/_proto/ankka/protocol/v1/discovery_pb2_grpc.py +142 -0
  76. ankka-0.4.0/src/ankka/_proto/ankka/protocol/v1/endpoint_pb2.py +49 -0
  77. ankka-0.4.0/src/ankka/_proto/ankka/protocol/v1/endpoint_pb2.pyi +81 -0
  78. ankka-0.4.0/src/ankka/_proto/ankka/protocol/v1/endpoint_pb2_grpc.py +140 -0
  79. ankka-0.4.0/src/ankka/_proto/ankka/protocol/v1/event_sourced_pb2.py +51 -0
  80. ankka-0.4.0/src/ankka/_proto/ankka/protocol/v1/event_sourced_pb2.pyi +75 -0
  81. ankka-0.4.0/src/ankka/_proto/ankka/protocol/v1/event_sourced_pb2_grpc.py +97 -0
  82. ankka-0.4.0/src/ankka/_proto/ankka/protocol/v1/key_value_pb2.py +47 -0
  83. ankka-0.4.0/src/ankka/_proto/ankka/protocol/v1/key_value_pb2.pyi +54 -0
  84. ankka-0.4.0/src/ankka/_proto/ankka/protocol/v1/key_value_pb2_grpc.py +97 -0
  85. ankka-0.4.0/src/ankka/_proto/ankka/protocol/v1/payload_pb2.py +60 -0
  86. ankka-0.4.0/src/ankka/_proto/ankka/protocol/v1/payload_pb2.pyi +106 -0
  87. ankka-0.4.0/src/ankka/_proto/ankka/protocol/v1/payload_pb2_grpc.py +24 -0
  88. ankka-0.4.0/src/ankka/_proto/ankka/protocol/v1/timed_action_pb2.py +41 -0
  89. ankka-0.4.0/src/ankka/_proto/ankka/protocol/v1/timed_action_pb2.pyi +27 -0
  90. ankka-0.4.0/src/ankka/_proto/ankka/protocol/v1/timed_action_pb2_grpc.py +97 -0
  91. ankka-0.4.0/src/ankka/_proto/ankka/protocol/v1/view_pb2.py +41 -0
  92. ankka-0.4.0/src/ankka/_proto/ankka/protocol/v1/view_pb2.pyi +31 -0
  93. ankka-0.4.0/src/ankka/_proto/ankka/protocol/v1/view_pb2_grpc.py +97 -0
  94. ankka-0.4.0/src/ankka/_proto/ankka/protocol/v1/workflow_pb2.py +59 -0
  95. ankka-0.4.0/src/ankka/_proto/ankka/protocol/v1/workflow_pb2.pyi +106 -0
  96. ankka-0.4.0/src/ankka/_proto/ankka/protocol/v1/workflow_pb2_grpc.py +97 -0
  97. ankka-0.4.0/src/ankka/agent.py +200 -0
  98. ankka-0.4.0/src/ankka/client.py +257 -0
  99. ankka-0.4.0/src/ankka/codec.py +502 -0
  100. ankka-0.4.0/src/ankka/consumer.py +71 -0
  101. ankka-0.4.0/src/ankka/context.py +119 -0
  102. ankka-0.4.0/src/ankka/effects/__init__.py +1 -0
  103. ankka-0.4.0/src/ankka/effects/agent.py +99 -0
  104. ankka-0.4.0/src/ankka/effects/common.py +103 -0
  105. ankka-0.4.0/src/ankka/effects/consumer.py +40 -0
  106. ankka-0.4.0/src/ankka/effects/event_sourced.py +83 -0
  107. ankka-0.4.0/src/ankka/effects/key_value.py +66 -0
  108. ankka-0.4.0/src/ankka/effects/timed_action.py +28 -0
  109. ankka-0.4.0/src/ankka/effects/view.py +37 -0
  110. ankka-0.4.0/src/ankka/effects/workflow.py +149 -0
  111. ankka-0.4.0/src/ankka/endpoint.py +216 -0
  112. ankka-0.4.0/src/ankka/event_sourced_entity.py +217 -0
  113. ankka-0.4.0/src/ankka/key_value_entity.py +88 -0
  114. ankka-0.4.0/src/ankka/py.typed +0 -0
  115. ankka-0.4.0/src/ankka/server.py +635 -0
  116. ankka-0.4.0/src/ankka/service.py +128 -0
  117. ankka-0.4.0/src/ankka/testkit/__init__.py +31 -0
  118. ankka-0.4.0/src/ankka/testkit/integration.py +252 -0
  119. ankka-0.4.0/src/ankka/testkit/unit.py +555 -0
  120. ankka-0.4.0/src/ankka/timed_action.py +64 -0
  121. ankka-0.4.0/src/ankka/view.py +94 -0
  122. ankka-0.4.0/src/ankka/workflow.py +227 -0
  123. ankka-0.4.0/tests/__init__.py +0 -0
  124. ankka-0.4.0/tests/counter.py +126 -0
  125. ankka-0.4.0/tests/kinds.py +159 -0
  126. ankka-0.4.0/tests/test_agent.py +108 -0
  127. ankka-0.4.0/tests/test_encoding_fixtures.py +175 -0
  128. ankka-0.4.0/tests/test_endpoint.py +43 -0
  129. ankka-0.4.0/tests/test_event_sourced_entity.py +89 -0
  130. ankka-0.4.0/tests/test_journal_portable.py +44 -0
  131. ankka-0.4.0/tests/test_other_kinds.py +119 -0
  132. ankka-0.4.0/tests/test_server_stream.py +131 -0
  133. ankka-0.4.0/tests/test_stream_close_spike.py +76 -0
  134. ankka-0.4.0/uv.lock +865 -0
ankka-0.4.0/.gitignore ADDED
@@ -0,0 +1,33 @@
1
+ target/
2
+ project/target/
3
+ project/project/
4
+ .bsp/
5
+ .bloop/
6
+ .metals/
7
+ .idea/
8
+ .vscode/
9
+ *.log
10
+ .DS_Store
11
+ .env
12
+
13
+ # Local-only Claude settings
14
+ .claude/settings.local.json
15
+
16
+ # Kubernetes: never commit cluster credentials or rendered secrets
17
+ kubeconfig*
18
+ .kube/
19
+ *.secret.yaml
20
+ *.key
21
+ *.crt
22
+
23
+ # Python (the SDK under sdks/python)
24
+ __pycache__/
25
+ *.pyc
26
+ .venv/
27
+ *.egg-info/
28
+ sdks/python/src/ankka/_proto/
29
+ .pytest_cache/
30
+ .mypy_cache/
31
+
32
+ # Claude Code worktrees: other sessions' checkouts, never part of this one
33
+ .claude/worktrees/
ankka-0.4.0/PKG-INFO ADDED
@@ -0,0 +1,50 @@
1
+ Metadata-Version: 2.5
2
+ Name: ankka
3
+ Version: 0.4.0
4
+ Summary: The ankka SDK for Python: components in Python, hosted by the ankka sidecar
5
+ Project-URL: Homepage, https://docs.ankka.cloud/
6
+ Project-URL: Documentation, https://docs.ankka.cloud/reference/python-sdk/
7
+ Project-URL: Repository, https://github.com/thinkmorestupidless/ankka
8
+ Project-URL: Changelog, https://github.com/thinkmorestupidless/ankka/releases
9
+ Author: Trevor Burton-McCreadie
10
+ License-Expression: Apache-2.0
11
+ Keywords: agents,ankka,event-sourcing,pekko,serverless,workflows
12
+ Classifier: Development Status :: 3 - Alpha
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Programming Language :: Python :: 3.13
17
+ Classifier: Topic :: Software Development :: Libraries :: Application Frameworks
18
+ Classifier: Typing :: Typed
19
+ Requires-Python: >=3.12
20
+ Requires-Dist: grpcio<2,>=1.84
21
+ Requires-Dist: protobuf<8,>=6
22
+ Provides-Extra: testkit
23
+ Requires-Dist: httpx>=0.27; extra == 'testkit'
24
+ Requires-Dist: testcontainers<5,>=4.15; extra == 'testkit'
25
+ Description-Content-Type: text/markdown
26
+
27
+ # ankka for Python
28
+
29
+ Components in Python — event sourced and key value entities, views, consumers, workflows, timed actions,
30
+ agents and HTTP endpoints — hosted by the ankka sidecar, the same runtime that hosts a Scala service.
31
+ Your process decides what should happen; the sidecar owns sharding, the journal, projections, timers,
32
+ HTTP, the agent loop and the model key.
33
+
34
+ - [Your first service in Python](https://docs.ankka.cloud/get-started/first-service-python/)
35
+ - [Services in other languages](https://docs.ankka.cloud/concepts/polyglot/) — how the sidecar model works
36
+ - [Python SDK reference](https://docs.ankka.cloud/reference/python-sdk/) — the API, and developing this SDK
37
+ - [Sidecar protocol](https://docs.ankka.cloud/reference/sidecar-protocol/)
38
+
39
+ The same pages are in this repository under
40
+ [`docs/`](https://github.com/thinkmorestupidless/ankka/tree/main/docs).
41
+
42
+ ```bash
43
+ uv add "ankka==<the version of the platform you deploy to>" # from PyPI, published with every release
44
+ ```
45
+
46
+ Developing the SDK itself, from this directory:
47
+
48
+ ```bash
49
+ uv sync && uv run python scripts/proto.py && uv run pytest -q && uv run mypy && uv run conformance
50
+ ```
ankka-0.4.0/README.md ADDED
@@ -0,0 +1,24 @@
1
+ # ankka for Python
2
+
3
+ Components in Python — event sourced and key value entities, views, consumers, workflows, timed actions,
4
+ agents and HTTP endpoints — hosted by the ankka sidecar, the same runtime that hosts a Scala service.
5
+ Your process decides what should happen; the sidecar owns sharding, the journal, projections, timers,
6
+ HTTP, the agent loop and the model key.
7
+
8
+ - [Your first service in Python](https://docs.ankka.cloud/get-started/first-service-python/)
9
+ - [Services in other languages](https://docs.ankka.cloud/concepts/polyglot/) — how the sidecar model works
10
+ - [Python SDK reference](https://docs.ankka.cloud/reference/python-sdk/) — the API, and developing this SDK
11
+ - [Sidecar protocol](https://docs.ankka.cloud/reference/sidecar-protocol/)
12
+
13
+ The same pages are in this repository under
14
+ [`docs/`](https://github.com/thinkmorestupidless/ankka/tree/main/docs).
15
+
16
+ ```bash
17
+ uv add "ankka==<the version of the platform you deploy to>" # from PyPI, published with every release
18
+ ```
19
+
20
+ Developing the SDK itself, from this directory:
21
+
22
+ ```bash
23
+ uv sync && uv run python scripts/proto.py && uv run pytest -q && uv run mypy && uv run conformance
24
+ ```
File without changes
@@ -0,0 +1,11 @@
1
+ # The developer's container: only their code and the SDK. The sidecar is the platform's.
2
+ # Plain pip, so the build needs nothing beyond Docker Hub's python image.
3
+ FROM python:3.12-slim
4
+ WORKDIR /app
5
+ COPY pyproject.toml README.md ./
6
+ COPY src ./src
7
+ COPY proto ./proto
8
+ RUN pip install --no-cache-dir . && rm -rf src proto
9
+ COPY examples ./examples
10
+ ENV ANKKA_PROCESS_PORT=9010
11
+ CMD ["python", "-m", "examples.shopping_cart.main"]
@@ -0,0 +1,2 @@
1
+ """The shopping cart, ported: the same wire names, routes and JSON as the Scala sample, so the
2
+ two share a journal."""
@@ -0,0 +1,49 @@
1
+ """An agent that answers questions about a cart. It declares its instructions, one tool and one
2
+ guardrail; the sidecar runs the loop — the model, memory, compaction — and asks this process to run
3
+ the tool and check the guardrail. No model key lives here."""
4
+
5
+ from __future__ import annotations
6
+
7
+ from dataclasses import dataclass
8
+
9
+ from ankka.agent import Agent, Guardrail, Tool, stream
10
+ from ankka.effects.agent import AgentEffect
11
+ from ankka.event_sourced_entity import command
12
+
13
+ from examples.shopping_cart.domain import ShoppingCart
14
+
15
+
16
+ @dataclass(frozen=True)
17
+ class CartLookup:
18
+ cartId: str
19
+
20
+
21
+ async def _lookup(agent: Agent, arguments: CartLookup) -> str:
22
+ assert agent.client is not None
23
+ cart = await agent.client.for_event_sourced_entity("shopping-cart", arguments.cartId).call("get-cart").invoke(reply=ShoppingCart)
24
+ if not cart.items:
25
+ return f"cart {arguments.cartId} is empty"
26
+ return ", ".join(f"{i.quantity} x {i.name}" for i in cart.items)
27
+
28
+
29
+ class CartAssistant(Agent):
30
+ component_id = "assistant"
31
+ tools = {"lookup": Tool("Looks up what is in a cart by its id.", _lookup, CartLookup)}
32
+ guardrails = {"no-secrets": Guardrail(lambda stage, text: "a key leaked" if stage == "output" and "sk-" in text else None)}
33
+
34
+ def _describe(self, question: str) -> AgentEffect[str]:
35
+ return (
36
+ self.effects.system_message("You help shoppers with their carts. Use the lookup tool before answering about a cart.")
37
+ .user_message(question)
38
+ .tools("lookup")
39
+ .guardrails("no-secrets")
40
+ .then_reply()
41
+ )
42
+
43
+ @command("ask")
44
+ def ask(self, question: str) -> AgentEffect[str]:
45
+ return self._describe(question)
46
+
47
+ @stream("chat")
48
+ def chat(self, question: str) -> AgentEffect[str]:
49
+ return self._describe(question)
@@ -0,0 +1,51 @@
1
+ """A queryable projection of every cart: the entity answers by cart id, this answers the rest —
2
+ which carts contain a product, which have been checked out, which are the largest."""
3
+
4
+ from __future__ import annotations
5
+
6
+ from dataclasses import dataclass, field, replace
7
+
8
+ from ankka import json_codec
9
+ from ankka.effects.view import ViewEffect
10
+ from ankka.view import View
11
+
12
+ from examples.shopping_cart.domain import CheckedOut, ItemAdded, ItemRemoved, ShoppingCartEvent
13
+ from examples.shopping_cart.entity import ShoppingCartEntity
14
+
15
+
16
+ @dataclass(frozen=True)
17
+ class CartRow:
18
+ cartId: str
19
+ quantities: dict[str, int] = field(default_factory=dict)
20
+ checkedOut: bool = False
21
+
22
+ @property
23
+ def total_quantity(self) -> int:
24
+ return sum(self.quantities.values())
25
+
26
+
27
+ class CartRows(View[ShoppingCartEvent, CartRow]):
28
+ component_id = "cart-rows"
29
+ source = ShoppingCartEntity
30
+ event_codec = ShoppingCartEntity.event_codec
31
+ row_codec = json_codec(CartRow, "cart-row")
32
+ queries = ("by-id", "all")
33
+
34
+ def on_change(self, event: ShoppingCartEvent) -> ViewEffect:
35
+ current = self.row or CartRow(self.metadata.subject or "")
36
+ match event:
37
+ case ItemAdded(item):
38
+ quantities = {**current.quantities, item.productId: current.quantities.get(item.productId, 0) + item.quantity}
39
+ return self.effects.update_row(replace(current, quantities=quantities))
40
+ case ItemRemoved(product_id):
41
+ return self.effects.update_row(replace(current, quantities={k: v for k, v in current.quantities.items() if k != product_id}))
42
+ case CheckedOut():
43
+ return self.effects.update_row(replace(current, checkedOut=True))
44
+ raise AssertionError(event)
45
+
46
+ def on_delete(self) -> ViewEffect:
47
+ """Checkout deletes the cart, but a checked-out cart is exactly what an order history
48
+ needs: the row outlives the entity that produced it."""
49
+ if self.row is None:
50
+ return self.effects.ignore()
51
+ return self.effects.update_row(replace(self.row, checkedOut=True))
@@ -0,0 +1,33 @@
1
+ """Where the notifier records checkouts: a key value entity per cart holding when it happened."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+
7
+ from ankka import DONE, Done, json_codec
8
+ from ankka.effects.key_value import KeyValueEffect, KeyValueReadOnlyEffect
9
+ from ankka.event_sourced_entity import command, query
10
+ from ankka.key_value_entity import KeyValueEntity
11
+
12
+
13
+ @dataclass(frozen=True)
14
+ class CheckoutRecord:
15
+ cartId: str
16
+ at: int = 0
17
+ notified: bool = False
18
+
19
+
20
+ class CheckoutLog(KeyValueEntity[CheckoutRecord]):
21
+ component_id = "checkout-log"
22
+ state_codec = json_codec(CheckoutRecord, "checkout-record")
23
+
24
+ def empty_state(self) -> CheckoutRecord:
25
+ return CheckoutRecord(self.entity_id)
26
+
27
+ @command("record")
28
+ def record(self, at: int) -> KeyValueEffect[CheckoutRecord, Done]:
29
+ return self.effects.update_state(CheckoutRecord(self.entity_id, at, True)).then_reply(lambda _: DONE)
30
+
31
+ @query("get")
32
+ def get(self) -> KeyValueReadOnlyEffect[CheckoutRecord, CheckoutRecord]:
33
+ return self.effects.reply(self.state)
@@ -0,0 +1,29 @@
1
+ """Turns an internal event into an action elsewhere: the cart's own events are an implementation
2
+ detail, and this consumer decides which are worth acting on. Here it records the checkout in
3
+ the checkout log through the client — a consumer that publishes to a topic instead declares
4
+ ``produces_to`` and an ``out_codec``, and the sidecar needs a broker (``ANKKA_KAFKA_BOOTSTRAP_SERVERS``)."""
5
+
6
+ from __future__ import annotations
7
+
8
+ import time
9
+
10
+ from ankka import Done
11
+ from ankka.consumer import Consumer
12
+ from ankka.effects.consumer import ConsumerEffect
13
+
14
+ from examples.shopping_cart.domain import CheckedOut, ShoppingCartEvent
15
+ from examples.shopping_cart.entity import ShoppingCartEntity
16
+
17
+
18
+ class CheckoutNotifier(Consumer[ShoppingCartEvent, None]):
19
+ component_id = "checkout-notifier"
20
+ source = ShoppingCartEntity
21
+ message_codec = ShoppingCartEntity.event_codec
22
+
23
+ async def on_message(self, event: ShoppingCartEvent) -> ConsumerEffect: # type: ignore[override]
24
+ if not isinstance(event, CheckedOut):
25
+ return self.effects.ignore()
26
+ cart_id = self.metadata.subject or ""
27
+ assert self.client is not None
28
+ await self.client.for_key_value_entity("checkout-log", cart_id).call("record").invoke(int(time.time() * 1000), reply=Done)
29
+ return self.effects.done()
@@ -0,0 +1,81 @@
1
+ """A checkout as a durable multi-step process: reserve the stock, charge the customer, and check
2
+ the cart out — or compensate. The sidecar journals every transition and runs the steps; a step
3
+ that throws is retried and failed over as declared in ``settings``."""
4
+
5
+ from __future__ import annotations
6
+
7
+ from dataclasses import dataclass, replace
8
+ from datetime import timedelta
9
+
10
+ from ankka import DONE, Done, ErrorCode, json_codec
11
+ from ankka.client import Calls
12
+ from ankka.effects.workflow import StepRef, WorkflowEffect, WorkflowReadOnlyEffect, WorkflowStepEffect
13
+ from ankka.event_sourced_entity import command, query
14
+ from ankka.workflow import Recovery, StepSettings, Workflow, WorkflowSettings, step
15
+
16
+ from examples.shopping_cart.domain import ShoppingCart
17
+
18
+
19
+ @dataclass(frozen=True)
20
+ class Checkout:
21
+ cartId: str
22
+ status: str = "new"
23
+ reserved: int = 0
24
+ mode: str = "ok"
25
+
26
+
27
+ class PaymentDeclined(Exception):
28
+ pass
29
+
30
+
31
+ # docs:start workflow
32
+ class CheckoutWorkflow(Workflow[Checkout]):
33
+ component_id = "checkout"
34
+ state_codec = json_codec(Checkout, "checkout")
35
+ settings = WorkflowSettings(
36
+ default_step_timeout=timedelta(seconds=10),
37
+ steps={"charge": StepSettings(recovery=Recovery(max_retries=1, failover_to="compensate"))},
38
+ )
39
+
40
+ def empty_state(self) -> Checkout:
41
+ return Checkout(self.entity_id)
42
+
43
+ @command("start")
44
+ def start(self, mode: str) -> WorkflowEffect[Checkout, Done]:
45
+ """``mode``: ``ok``, ``fail`` (the charge is declined) or ``pause`` (a pause before it)."""
46
+ if self.state.status != "new":
47
+ return self.effects.error(f"checkout is already {self.state.status}", ErrorCode.CONFLICT)
48
+ return self.effects.update_state(replace(self.state, status="reserving", mode=mode)).then_transition_to("reserve").then_reply(lambda _: DONE)
49
+
50
+ @query("status")
51
+ def status(self) -> WorkflowReadOnlyEffect[Checkout, Checkout]:
52
+ return self.effects.reply(self.state)
53
+
54
+ @step("reserve")
55
+ async def reserve(self) -> WorkflowStepEffect[Checkout]:
56
+ # A client call from a step: what the cart holds.
57
+ total = await self._cart().call("total-quantity").invoke(reply=int)
58
+ next_step = "wait" if self.state.mode == "pause" else "charge"
59
+ return self.step_effects.update_state(replace(self.state, status="reserved", reserved=total)).then_transition_to(next_step)
60
+
61
+ @step("wait")
62
+ def wait(self) -> WorkflowStepEffect[Checkout]:
63
+ return self.step_effects.update_state(replace(self.state, status="waiting")).then_pause(after=timedelta(milliseconds=1500), on_timeout=StepRef("charge"))
64
+
65
+ @step("charge")
66
+ async def charge(self) -> WorkflowStepEffect[Checkout]:
67
+ if self.state.mode == "fail":
68
+ raise PaymentDeclined("payment declined")
69
+ # Not idempotent — a retry after the cart was checked out is refused — which is why
70
+ # ``charge`` is allowed one retry and then fails over, and why compensation exists.
71
+ if self.state.reserved > 0:
72
+ await self._cart().call("checkout").invoke(reply=ShoppingCart)
73
+ return self.step_effects.update_state(replace(self.state, status="charged")).then_end()
74
+
75
+ @step("compensate")
76
+ def compensate(self) -> WorkflowStepEffect[Checkout]:
77
+ return self.step_effects.update_state(replace(self.state, status="compensated", reserved=0)).then_end()
78
+
79
+ def _cart(self) -> Calls:
80
+ return self.context.client.for_event_sourced_entity("shopping-cart", self.state.cartId)
81
+ # docs:end workflow