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.
- ankka-0.4.0/.gitignore +33 -0
- ankka-0.4.0/PKG-INFO +50 -0
- ankka-0.4.0/README.md +24 -0
- ankka-0.4.0/examples/__init__.py +0 -0
- ankka-0.4.0/examples/shopping_cart/Dockerfile +11 -0
- ankka-0.4.0/examples/shopping_cart/__init__.py +2 -0
- ankka-0.4.0/examples/shopping_cart/assistant.py +49 -0
- ankka-0.4.0/examples/shopping_cart/cart_rows.py +51 -0
- ankka-0.4.0/examples/shopping_cart/checkout_log.py +33 -0
- ankka-0.4.0/examples/shopping_cart/checkout_notifier.py +29 -0
- ankka-0.4.0/examples/shopping_cart/checkout_workflow.py +81 -0
- ankka-0.4.0/examples/shopping_cart/conformance.py +315 -0
- ankka-0.4.0/examples/shopping_cart/domain.py +68 -0
- ankka-0.4.0/examples/shopping_cart/endpoint.py +94 -0
- ankka-0.4.0/examples/shopping_cart/entity.py +59 -0
- ankka-0.4.0/examples/shopping_cart/main.py +40 -0
- ankka-0.4.0/examples/shopping_cart/service.json +8 -0
- ankka-0.4.0/examples/shopping_cart/test_cart.py +245 -0
- ankka-0.4.0/proto/ENCODING.md +78 -0
- ankka-0.4.0/proto/README.md +64 -0
- ankka-0.4.0/proto/fixtures/binary-bytes.json +6 -0
- ankka-0.4.0/proto/fixtures/binary-done.json +6 -0
- ankka-0.4.0/proto/fixtures/binary-option-none.json +6 -0
- ankka-0.4.0/proto/fixtures/binary-option-some.json +6 -0
- ankka-0.4.0/proto/fixtures/binary-unit.json +6 -0
- ankka-0.4.0/proto/fixtures/enum-as-field.json +6 -0
- ankka-0.4.0/proto/fixtures/numbers.json +6 -0
- ankka-0.4.0/proto/fixtures/record-empty-collection.json +6 -0
- ankka-0.4.0/proto/fixtures/record-instants-and-options.json +6 -0
- ankka-0.4.0/proto/fixtures/record-nested-and-map.json +6 -0
- ankka-0.4.0/proto/fixtures/record-with-collection.json +6 -0
- ankka-0.4.0/proto/fixtures/recursive.json +6 -0
- ankka-0.4.0/proto/fixtures/sum-type-fieldless.json +6 -0
- ankka-0.4.0/proto/fixtures/sum-type-optional-absent.json +6 -0
- ankka-0.4.0/proto/fixtures/sum-type-string-field.json +6 -0
- ankka-0.4.0/proto/fixtures/sum-type-with-fields.json +6 -0
- ankka-0.4.0/proto/fixtures/sum-type-with-instant.json +6 -0
- ankka-0.4.0/proto/fixtures/text-boolean.json +6 -0
- ankka-0.4.0/proto/fixtures/text-double.json +6 -0
- ankka-0.4.0/proto/fixtures/text-duration-millis.json +6 -0
- ankka-0.4.0/proto/fixtures/text-int.json +6 -0
- ankka-0.4.0/proto/fixtures/text-long.json +6 -0
- ankka-0.4.0/proto/fixtures/text-string.json +6 -0
- ankka-0.4.0/proto/src/main/protobuf/ankka/protocol/v1/agent.proto +53 -0
- ankka-0.4.0/proto/src/main/protobuf/ankka/protocol/v1/client.proto +43 -0
- ankka-0.4.0/proto/src/main/protobuf/ankka/protocol/v1/consumer.proto +19 -0
- ankka-0.4.0/proto/src/main/protobuf/ankka/protocol/v1/discovery.proto +101 -0
- ankka-0.4.0/proto/src/main/protobuf/ankka/protocol/v1/endpoint.proto +46 -0
- ankka-0.4.0/proto/src/main/protobuf/ankka/protocol/v1/event_sourced.proto +38 -0
- ankka-0.4.0/proto/src/main/protobuf/ankka/protocol/v1/key_value.proto +24 -0
- ankka-0.4.0/proto/src/main/protobuf/ankka/protocol/v1/payload.proto +53 -0
- ankka-0.4.0/proto/src/main/protobuf/ankka/protocol/v1/timed_action.proto +11 -0
- ankka-0.4.0/proto/src/main/protobuf/ankka/protocol/v1/view.proto +17 -0
- ankka-0.4.0/proto/src/main/protobuf/ankka/protocol/v1/workflow.proto +39 -0
- ankka-0.4.0/pyproject.toml +88 -0
- ankka-0.4.0/scripts/conformance.py +10 -0
- ankka-0.4.0/scripts/proto.py +73 -0
- ankka-0.4.0/src/ankka/__init__.py +18 -0
- ankka-0.4.0/src/ankka/_conformance.py +56 -0
- ankka-0.4.0/src/ankka/_proto/__init__.py +0 -0
- ankka-0.4.0/src/ankka/_proto/ankka/__init__.py +0 -0
- ankka-0.4.0/src/ankka/_proto/ankka/protocol/__init__.py +0 -0
- ankka-0.4.0/src/ankka/_proto/ankka/protocol/v1/__init__.py +0 -0
- ankka-0.4.0/src/ankka/_proto/ankka/protocol/v1/agent_pb2.py +59 -0
- ankka-0.4.0/src/ankka/_proto/ankka/protocol/v1/agent_pb2.pyi +118 -0
- ankka-0.4.0/src/ankka/_proto/ankka/protocol/v1/agent_pb2_grpc.py +183 -0
- ankka-0.4.0/src/ankka/_proto/ankka/protocol/v1/client_pb2.py +52 -0
- ankka-0.4.0/src/ankka/_proto/ankka/protocol/v1/client_pb2.pyi +84 -0
- ankka-0.4.0/src/ankka/_proto/ankka/protocol/v1/client_pb2_grpc.py +270 -0
- ankka-0.4.0/src/ankka/_proto/ankka/protocol/v1/consumer_pb2.py +43 -0
- ankka-0.4.0/src/ankka/_proto/ankka/protocol/v1/consumer_pb2.pyi +36 -0
- ankka-0.4.0/src/ankka/_proto/ankka/protocol/v1/consumer_pb2_grpc.py +97 -0
- ankka-0.4.0/src/ankka/_proto/ankka/protocol/v1/discovery_pb2.py +83 -0
- ankka-0.4.0/src/ankka/_proto/ankka/protocol/v1/discovery_pb2.pyi +232 -0
- ankka-0.4.0/src/ankka/_proto/ankka/protocol/v1/discovery_pb2_grpc.py +142 -0
- ankka-0.4.0/src/ankka/_proto/ankka/protocol/v1/endpoint_pb2.py +49 -0
- ankka-0.4.0/src/ankka/_proto/ankka/protocol/v1/endpoint_pb2.pyi +81 -0
- ankka-0.4.0/src/ankka/_proto/ankka/protocol/v1/endpoint_pb2_grpc.py +140 -0
- ankka-0.4.0/src/ankka/_proto/ankka/protocol/v1/event_sourced_pb2.py +51 -0
- ankka-0.4.0/src/ankka/_proto/ankka/protocol/v1/event_sourced_pb2.pyi +75 -0
- ankka-0.4.0/src/ankka/_proto/ankka/protocol/v1/event_sourced_pb2_grpc.py +97 -0
- ankka-0.4.0/src/ankka/_proto/ankka/protocol/v1/key_value_pb2.py +47 -0
- ankka-0.4.0/src/ankka/_proto/ankka/protocol/v1/key_value_pb2.pyi +54 -0
- ankka-0.4.0/src/ankka/_proto/ankka/protocol/v1/key_value_pb2_grpc.py +97 -0
- ankka-0.4.0/src/ankka/_proto/ankka/protocol/v1/payload_pb2.py +60 -0
- ankka-0.4.0/src/ankka/_proto/ankka/protocol/v1/payload_pb2.pyi +106 -0
- ankka-0.4.0/src/ankka/_proto/ankka/protocol/v1/payload_pb2_grpc.py +24 -0
- ankka-0.4.0/src/ankka/_proto/ankka/protocol/v1/timed_action_pb2.py +41 -0
- ankka-0.4.0/src/ankka/_proto/ankka/protocol/v1/timed_action_pb2.pyi +27 -0
- ankka-0.4.0/src/ankka/_proto/ankka/protocol/v1/timed_action_pb2_grpc.py +97 -0
- ankka-0.4.0/src/ankka/_proto/ankka/protocol/v1/view_pb2.py +41 -0
- ankka-0.4.0/src/ankka/_proto/ankka/protocol/v1/view_pb2.pyi +31 -0
- ankka-0.4.0/src/ankka/_proto/ankka/protocol/v1/view_pb2_grpc.py +97 -0
- ankka-0.4.0/src/ankka/_proto/ankka/protocol/v1/workflow_pb2.py +59 -0
- ankka-0.4.0/src/ankka/_proto/ankka/protocol/v1/workflow_pb2.pyi +106 -0
- ankka-0.4.0/src/ankka/_proto/ankka/protocol/v1/workflow_pb2_grpc.py +97 -0
- ankka-0.4.0/src/ankka/agent.py +200 -0
- ankka-0.4.0/src/ankka/client.py +257 -0
- ankka-0.4.0/src/ankka/codec.py +502 -0
- ankka-0.4.0/src/ankka/consumer.py +71 -0
- ankka-0.4.0/src/ankka/context.py +119 -0
- ankka-0.4.0/src/ankka/effects/__init__.py +1 -0
- ankka-0.4.0/src/ankka/effects/agent.py +99 -0
- ankka-0.4.0/src/ankka/effects/common.py +103 -0
- ankka-0.4.0/src/ankka/effects/consumer.py +40 -0
- ankka-0.4.0/src/ankka/effects/event_sourced.py +83 -0
- ankka-0.4.0/src/ankka/effects/key_value.py +66 -0
- ankka-0.4.0/src/ankka/effects/timed_action.py +28 -0
- ankka-0.4.0/src/ankka/effects/view.py +37 -0
- ankka-0.4.0/src/ankka/effects/workflow.py +149 -0
- ankka-0.4.0/src/ankka/endpoint.py +216 -0
- ankka-0.4.0/src/ankka/event_sourced_entity.py +217 -0
- ankka-0.4.0/src/ankka/key_value_entity.py +88 -0
- ankka-0.4.0/src/ankka/py.typed +0 -0
- ankka-0.4.0/src/ankka/server.py +635 -0
- ankka-0.4.0/src/ankka/service.py +128 -0
- ankka-0.4.0/src/ankka/testkit/__init__.py +31 -0
- ankka-0.4.0/src/ankka/testkit/integration.py +252 -0
- ankka-0.4.0/src/ankka/testkit/unit.py +555 -0
- ankka-0.4.0/src/ankka/timed_action.py +64 -0
- ankka-0.4.0/src/ankka/view.py +94 -0
- ankka-0.4.0/src/ankka/workflow.py +227 -0
- ankka-0.4.0/tests/__init__.py +0 -0
- ankka-0.4.0/tests/counter.py +126 -0
- ankka-0.4.0/tests/kinds.py +159 -0
- ankka-0.4.0/tests/test_agent.py +108 -0
- ankka-0.4.0/tests/test_encoding_fixtures.py +175 -0
- ankka-0.4.0/tests/test_endpoint.py +43 -0
- ankka-0.4.0/tests/test_event_sourced_entity.py +89 -0
- ankka-0.4.0/tests/test_journal_portable.py +44 -0
- ankka-0.4.0/tests/test_other_kinds.py +119 -0
- ankka-0.4.0/tests/test_server_stream.py +131 -0
- ankka-0.4.0/tests/test_stream_close_spike.py +76 -0
- 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,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
|