plexspaces 0.1.1__py3-none-any.whl
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.
- plexspaces/__init__.py +80 -0
- plexspaces/actor_id.py +114 -0
- plexspaces/decorators.py +691 -0
- plexspaces/generated/__init__.py +0 -0
- plexspaces/generated/buf/__init__.py +0 -0
- plexspaces/generated/buf/validate/__init__.py +3799 -0
- plexspaces/generated/google/__init__.py +0 -0
- plexspaces/generated/google/api/__init__.py +379 -0
- plexspaces/generated/grpc/__init__.py +0 -0
- plexspaces/generated/grpc/gateway/__init__.py +0 -0
- plexspaces/generated/grpc/gateway/protoc_gen_openapiv2/__init__.py +0 -0
- plexspaces/generated/grpc/gateway/protoc_gen_openapiv2/options/__init__.py +1022 -0
- plexspaces/generated/plexspaces/__init__.py +0 -0
- plexspaces/generated/plexspaces/actor/__init__.py +0 -0
- plexspaces/generated/plexspaces/actor/v1/__init__.py +3538 -0
- plexspaces/generated/plexspaces/application/__init__.py +0 -0
- plexspaces/generated/plexspaces/application/v1/__init__.py +827 -0
- plexspaces/generated/plexspaces/behaviors/__init__.py +0 -0
- plexspaces/generated/plexspaces/behaviors/v1/__init__.py +63 -0
- plexspaces/generated/plexspaces/channel/__init__.py +0 -0
- plexspaces/generated/plexspaces/channel/v1/__init__.py +915 -0
- plexspaces/generated/plexspaces/circuitbreaker/__init__.py +0 -0
- plexspaces/generated/plexspaces/circuitbreaker/prv/__init__.py +398 -0
- plexspaces/generated/plexspaces/common/__init__.py +0 -0
- plexspaces/generated/plexspaces/common/v1/__init__.py +691 -0
- plexspaces/generated/plexspaces/config/__init__.py +0 -0
- plexspaces/generated/plexspaces/config/v1/__init__.py +109 -0
- plexspaces/generated/plexspaces/dashboard/__init__.py +0 -0
- plexspaces/generated/plexspaces/dashboard/v1/__init__.py +647 -0
- plexspaces/generated/plexspaces/facets/__init__.py +0 -0
- plexspaces/generated/plexspaces/facets/v1/__init__.py +333 -0
- plexspaces/generated/plexspaces/firecracker/__init__.py +0 -0
- plexspaces/generated/plexspaces/firecracker/v1/__init__.py +890 -0
- plexspaces/generated/plexspaces/grpc/__init__.py +0 -0
- plexspaces/generated/plexspaces/grpc/v1/__init__.py +679 -0
- plexspaces/generated/plexspaces/journaling/__init__.py +0 -0
- plexspaces/generated/plexspaces/journaling/v1/__init__.py +1006 -0
- plexspaces/generated/plexspaces/keyvalue/__init__.py +0 -0
- plexspaces/generated/plexspaces/keyvalue/v1/__init__.py +1007 -0
- plexspaces/generated/plexspaces/locks/__init__.py +0 -0
- plexspaces/generated/plexspaces/locks/prv/__init__.py +122 -0
- plexspaces/generated/plexspaces/mailbox/__init__.py +0 -0
- plexspaces/generated/plexspaces/mailbox/v1/__init__.py +246 -0
- plexspaces/generated/plexspaces/metrics/__init__.py +0 -0
- plexspaces/generated/plexspaces/metrics/v1/__init__.py +878 -0
- plexspaces/generated/plexspaces/node/__init__.py +0 -0
- plexspaces/generated/plexspaces/node/v1/__init__.py +2564 -0
- plexspaces/generated/plexspaces/object_registry/__init__.py +0 -0
- plexspaces/generated/plexspaces/object_registry/v1/__init__.py +759 -0
- plexspaces/generated/plexspaces/persistence/__init__.py +0 -0
- plexspaces/generated/plexspaces/persistence/prv/__init__.py +539 -0
- plexspaces/generated/plexspaces/pool/__init__.py +0 -0
- plexspaces/generated/plexspaces/pool/v1/__init__.py +791 -0
- plexspaces/generated/plexspaces/processgroups/__init__.py +0 -0
- plexspaces/generated/plexspaces/processgroups/v1/__init__.py +622 -0
- plexspaces/generated/plexspaces/scheduling/__init__.py +0 -0
- plexspaces/generated/plexspaces/scheduling/v1/__init__.py +364 -0
- plexspaces/generated/plexspaces/security/__init__.py +0 -0
- plexspaces/generated/plexspaces/security/v1/__init__.py +254 -0
- plexspaces/generated/plexspaces/services/__init__.py +0 -0
- plexspaces/generated/plexspaces/services/prv/__init__.py +112 -0
- plexspaces/generated/plexspaces/storage/__init__.py +0 -0
- plexspaces/generated/plexspaces/storage/v1/__init__.py +747 -0
- plexspaces/generated/plexspaces/supervision/__init__.py +0 -0
- plexspaces/generated/plexspaces/supervision/v1/__init__.py +536 -0
- plexspaces/generated/plexspaces/system/__init__.py +0 -0
- plexspaces/generated/plexspaces/system/v1/__init__.py +1602 -0
- plexspaces/generated/plexspaces/timer/__init__.py +0 -0
- plexspaces/generated/plexspaces/timer/v1/__init__.py +402 -0
- plexspaces/generated/plexspaces/tuplespace/__init__.py +0 -0
- plexspaces/generated/plexspaces/tuplespace/v1/__init__.py +869 -0
- plexspaces/generated/plexspaces/wasm/__init__.py +0 -0
- plexspaces/generated/plexspaces/wasm/v1/__init__.py +920 -0
- plexspaces/generated/plexspaces/workflow/__init__.py +0 -0
- plexspaces/generated/plexspaces/workflow/v1/__init__.py +870 -0
- plexspaces/host.py +2093 -0
- plexspaces/leader_worker.py +148 -0
- plexspaces/proto_wire.py +1357 -0
- plexspaces/runtime.py +284 -0
- plexspaces/workflow.py +105 -0
- plexspaces-0.1.1.dist-info/METADATA +505 -0
- plexspaces-0.1.1.dist-info/RECORD +87 -0
- plexspaces-0.1.1.dist-info/WHEEL +5 -0
- plexspaces-0.1.1.dist-info/entry_points.txt +2 -0
- plexspaces-0.1.1.dist-info/top_level.txt +2 -0
- plexspaces_cli/__init__.py +8 -0
- plexspaces_cli/build.py +374 -0
plexspaces/decorators.py
ADDED
|
@@ -0,0 +1,691 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
# Copyright (C) 2025 PlexSpaces Contributors
|
|
3
|
+
#
|
|
4
|
+
# PlexSpaces Actor Decorators
|
|
5
|
+
#
|
|
6
|
+
# Provides @actor, @handler, and state() for defining PlexSpaces actors
|
|
7
|
+
# with minimal boilerplate (inspired by Ray's @ray.remote).
|
|
8
|
+
|
|
9
|
+
"""
|
|
10
|
+
Actor decorators for PlexSpaces Python SDK.
|
|
11
|
+
|
|
12
|
+
Usage:
|
|
13
|
+
from plexspaces import actor, handler, state
|
|
14
|
+
|
|
15
|
+
@actor
|
|
16
|
+
class Counter:
|
|
17
|
+
count: int = state(default=0)
|
|
18
|
+
|
|
19
|
+
@handler("increment")
|
|
20
|
+
def increment(self, amount: int = 1) -> dict:
|
|
21
|
+
self.count += amount
|
|
22
|
+
return {"count": self.count}
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
import inspect
|
|
26
|
+
import json
|
|
27
|
+
import functools
|
|
28
|
+
from typing import Any, Callable, Dict, List, Optional, Type, TypeVar, Union
|
|
29
|
+
from dataclasses import dataclass, field
|
|
30
|
+
|
|
31
|
+
T = TypeVar('T')
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass
|
|
35
|
+
class StateField:
|
|
36
|
+
"""Descriptor for state fields that should be persisted."""
|
|
37
|
+
name: str = ""
|
|
38
|
+
default: Any = None
|
|
39
|
+
default_factory: Optional[Callable] = None
|
|
40
|
+
|
|
41
|
+
def __set_name__(self, owner: Type, name: str) -> None:
|
|
42
|
+
self.name = name
|
|
43
|
+
|
|
44
|
+
def __get__(self, obj: Any, objtype: Type = None) -> Any:
|
|
45
|
+
if obj is None:
|
|
46
|
+
return self
|
|
47
|
+
attr_name = f"_state_{self.name}"
|
|
48
|
+
if not hasattr(obj, attr_name):
|
|
49
|
+
# Initialize with default on first access
|
|
50
|
+
setattr(obj, attr_name, self.get_default())
|
|
51
|
+
return getattr(obj, attr_name)
|
|
52
|
+
|
|
53
|
+
def __set__(self, obj: Any, value: Any) -> None:
|
|
54
|
+
setattr(obj, f"_state_{self.name}", value)
|
|
55
|
+
|
|
56
|
+
def get_default(self) -> Any:
|
|
57
|
+
if self.default_factory is not None:
|
|
58
|
+
return self.default_factory()
|
|
59
|
+
return self.default
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def state(default: Any = None, default_factory: Callable = None) -> StateField:
|
|
63
|
+
"""
|
|
64
|
+
Define a persistent state field for an actor.
|
|
65
|
+
|
|
66
|
+
State fields are automatically serialized in get_state() and
|
|
67
|
+
restored in set_state(). Use this for any data that should
|
|
68
|
+
survive actor restarts.
|
|
69
|
+
|
|
70
|
+
Args:
|
|
71
|
+
default: Default value for the field (for immutable types)
|
|
72
|
+
default_factory: Factory function for default (for mutable types like list, dict)
|
|
73
|
+
|
|
74
|
+
Returns:
|
|
75
|
+
StateField descriptor
|
|
76
|
+
|
|
77
|
+
Example:
|
|
78
|
+
@actor
|
|
79
|
+
class MyActor:
|
|
80
|
+
count: int = state(default=0)
|
|
81
|
+
items: list = state(default_factory=list)
|
|
82
|
+
"""
|
|
83
|
+
return StateField(default=default, default_factory=default_factory)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@dataclass
|
|
87
|
+
class HandlerInfo:
|
|
88
|
+
"""Metadata for a message handler."""
|
|
89
|
+
method: Callable
|
|
90
|
+
msg_types: List[str]
|
|
91
|
+
invocation: str = "call" # "call" (request-reply) or "cast" (fire-and-forget)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@dataclass
|
|
95
|
+
class WorkflowHandlerInfo:
|
|
96
|
+
"""Metadata for workflow-specific handlers."""
|
|
97
|
+
method: Callable
|
|
98
|
+
names: List[str] = field(default_factory=list)
|
|
99
|
+
|
|
100
|
+
def handler(*args, invocation: str = "call") -> Callable:
|
|
101
|
+
"""
|
|
102
|
+
Decorator to mark a method as a message handler.
|
|
103
|
+
|
|
104
|
+
The method will be called when a message with any of the specified
|
|
105
|
+
types is received. Arguments are extracted from the JSON payload.
|
|
106
|
+
|
|
107
|
+
Args:
|
|
108
|
+
*args: Message type(s) to handle. You may include the delivery invocation as
|
|
109
|
+
an additional positional argument, for example `@handler("op", "call")`.
|
|
110
|
+
invocation: "call" (request-reply, default) or "cast" (fire-and-forget).
|
|
111
|
+
For GenServer actors, always use "call" since they return responses.
|
|
112
|
+
|
|
113
|
+
Returns:
|
|
114
|
+
Decorated method
|
|
115
|
+
|
|
116
|
+
Example:
|
|
117
|
+
@actor
|
|
118
|
+
class PaymentHandler:
|
|
119
|
+
@handler("process", "call") # request-reply (returns response)
|
|
120
|
+
def process(self, amount: int) -> dict:
|
|
121
|
+
return {"status": "ok", "amount": amount}
|
|
122
|
+
|
|
123
|
+
@handler("audit", "cast") # fire-and-forget (no response needed)
|
|
124
|
+
def audit(self, event: str) -> None:
|
|
125
|
+
self.log_event(event)
|
|
126
|
+
|
|
127
|
+
# Keyword syntax also works:
|
|
128
|
+
@handler("balance", invocation="call")
|
|
129
|
+
def get_balance(self) -> dict:
|
|
130
|
+
return {"balance": self.balance}
|
|
131
|
+
"""
|
|
132
|
+
# Parse args: allow @handler("op", "call") as shorthand for selecting ask/tell behavior.
|
|
133
|
+
msg_types = []
|
|
134
|
+
effective_invocation = invocation
|
|
135
|
+
|
|
136
|
+
for arg in args:
|
|
137
|
+
if arg in ("call", "cast"):
|
|
138
|
+
effective_invocation = arg
|
|
139
|
+
else:
|
|
140
|
+
msg_types.append(arg)
|
|
141
|
+
|
|
142
|
+
def decorator(method: Callable) -> Callable:
|
|
143
|
+
# Store handler info as attribute
|
|
144
|
+
method._plexspaces_handler = HandlerInfo(
|
|
145
|
+
method=method,
|
|
146
|
+
msg_types=msg_types,
|
|
147
|
+
invocation=effective_invocation
|
|
148
|
+
)
|
|
149
|
+
return method
|
|
150
|
+
return decorator
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def init_handler(method: Callable) -> Callable:
|
|
154
|
+
"""
|
|
155
|
+
Decorator to mark a method as the initialization handler.
|
|
156
|
+
|
|
157
|
+
Called when the actor receives config during init().
|
|
158
|
+
If not specified, config values are set directly on state fields.
|
|
159
|
+
|
|
160
|
+
Example:
|
|
161
|
+
@actor
|
|
162
|
+
class MyActor:
|
|
163
|
+
@init_handler
|
|
164
|
+
def on_init(self, config: dict):
|
|
165
|
+
self.account_id = config.get("account_id", "")
|
|
166
|
+
"""
|
|
167
|
+
method._plexspaces_init_handler = True
|
|
168
|
+
return method
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def run_handler(method: Callable) -> Callable:
|
|
172
|
+
"""Decorator to mark a workflow run handler."""
|
|
173
|
+
method._plexspaces_run_handler = True
|
|
174
|
+
return method
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def signal_handler(*names: str) -> Callable:
|
|
178
|
+
"""Decorator to mark a workflow signal handler."""
|
|
179
|
+
def decorator(method: Callable) -> Callable:
|
|
180
|
+
method._plexspaces_signal_handler = WorkflowHandlerInfo(
|
|
181
|
+
method=method,
|
|
182
|
+
names=list(names),
|
|
183
|
+
)
|
|
184
|
+
return method
|
|
185
|
+
return decorator
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def query_handler(*names: str) -> Callable:
|
|
189
|
+
"""Decorator to mark a workflow query handler."""
|
|
190
|
+
def decorator(method: Callable) -> Callable:
|
|
191
|
+
method._plexspaces_query_handler = WorkflowHandlerInfo(
|
|
192
|
+
method=method,
|
|
193
|
+
names=list(names),
|
|
194
|
+
)
|
|
195
|
+
return method
|
|
196
|
+
return decorator
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
class ActorMeta(type):
|
|
200
|
+
"""Metaclass for PlexSpaces actors."""
|
|
201
|
+
|
|
202
|
+
def __new__(mcs, name: str, bases: tuple, namespace: dict) -> Type:
|
|
203
|
+
# Collect state fields
|
|
204
|
+
state_fields: Dict[str, StateField] = {}
|
|
205
|
+
for attr_name, attr_value in namespace.items():
|
|
206
|
+
if isinstance(attr_value, StateField):
|
|
207
|
+
state_fields[attr_name] = attr_value
|
|
208
|
+
|
|
209
|
+
# Collect handlers
|
|
210
|
+
handlers: Dict[str, Callable] = {}
|
|
211
|
+
init_handler_method: Optional[Callable] = None
|
|
212
|
+
run_handler_method: Optional[Callable] = None
|
|
213
|
+
signal_handlers: Dict[str, Callable] = {}
|
|
214
|
+
query_handlers: Dict[str, Callable] = {}
|
|
215
|
+
for attr_name, attr_value in namespace.items():
|
|
216
|
+
if callable(attr_value):
|
|
217
|
+
if hasattr(attr_value, '_plexspaces_handler'):
|
|
218
|
+
info: HandlerInfo = attr_value._plexspaces_handler
|
|
219
|
+
for msg_type in info.msg_types:
|
|
220
|
+
handlers[msg_type] = attr_value
|
|
221
|
+
if hasattr(attr_value, '_plexspaces_init_handler'):
|
|
222
|
+
init_handler_method = attr_value
|
|
223
|
+
if hasattr(attr_value, '_plexspaces_run_handler'):
|
|
224
|
+
run_handler_method = attr_value
|
|
225
|
+
if hasattr(attr_value, '_plexspaces_signal_handler'):
|
|
226
|
+
info: WorkflowHandlerInfo = attr_value._plexspaces_signal_handler
|
|
227
|
+
for signal_name in info.names:
|
|
228
|
+
signal_handlers[signal_name] = attr_value
|
|
229
|
+
if hasattr(attr_value, '_plexspaces_query_handler'):
|
|
230
|
+
info: WorkflowHandlerInfo = attr_value._plexspaces_query_handler
|
|
231
|
+
for query_name in info.names:
|
|
232
|
+
query_handlers[query_name] = attr_value
|
|
233
|
+
|
|
234
|
+
# Store metadata in class
|
|
235
|
+
namespace['_plexspaces_state_fields'] = state_fields
|
|
236
|
+
namespace['_plexspaces_handlers'] = handlers
|
|
237
|
+
namespace['_plexspaces_init_handler'] = init_handler_method
|
|
238
|
+
namespace['_plexspaces_run_handler'] = run_handler_method
|
|
239
|
+
namespace['_plexspaces_signal_handlers'] = signal_handlers
|
|
240
|
+
namespace['_plexspaces_query_handlers'] = query_handlers
|
|
241
|
+
namespace['_plexspaces_is_actor'] = True
|
|
242
|
+
|
|
243
|
+
return super().__new__(mcs, name, bases, namespace)
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
# OTP-inspired behavior types (matches crates/behavior and plexspaces_core::BehaviorType)
|
|
247
|
+
BEHAVIOR_GEN_SERVER = "GenServer"
|
|
248
|
+
BEHAVIOR_GEN_EVENT = "GenEvent"
|
|
249
|
+
BEHAVIOR_GEN_STATE_MACHINE = "GenStateMachine"
|
|
250
|
+
BEHAVIOR_WORKFLOW = "Workflow"
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _actor_with_behavior(cls: Type[T], behavior_type: str, facets: Optional[List[str]] = None) -> Type[T]:
|
|
254
|
+
"""Apply ActorMeta and set __behavior_type__ and __facets__ on the class."""
|
|
255
|
+
if not isinstance(cls, ActorMeta):
|
|
256
|
+
new_cls = ActorMeta(cls.__name__, cls.__bases__, dict(cls.__dict__))
|
|
257
|
+
new_cls.__module__ = cls.__module__
|
|
258
|
+
new_cls.__qualname__ = cls.__qualname__
|
|
259
|
+
cls = new_cls
|
|
260
|
+
setattr(cls, "__behavior_type__", behavior_type)
|
|
261
|
+
setattr(cls, "__facets__", facets or [])
|
|
262
|
+
# Workflow behavior: dispatch_message routes workflow_run / workflow_signal:x / workflow_query:x to run/signal/query
|
|
263
|
+
setattr(cls, "_plexspaces_workflow", behavior_type == BEHAVIOR_WORKFLOW)
|
|
264
|
+
|
|
265
|
+
# GenServer always uses request-reply (call), so normalize handlers to that invocation.
|
|
266
|
+
if behavior_type == BEHAVIOR_GEN_SERVER:
|
|
267
|
+
for attr_name in dir(cls):
|
|
268
|
+
attr = getattr(cls, attr_name, None)
|
|
269
|
+
if callable(attr) and hasattr(attr, '_plexspaces_handler'):
|
|
270
|
+
handler_info: HandlerInfo = attr._plexspaces_handler
|
|
271
|
+
if handler_info.invocation != "call":
|
|
272
|
+
handler_info.invocation = "call"
|
|
273
|
+
|
|
274
|
+
return cls
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def actor(
|
|
278
|
+
cls: Type[T] = None,
|
|
279
|
+
*,
|
|
280
|
+
facets: Optional[List[str]] = None
|
|
281
|
+
) -> Union[Type[T], Callable[[Type[T]], Type[T]]]:
|
|
282
|
+
"""
|
|
283
|
+
Decorator to define a PlexSpaces actor class.
|
|
284
|
+
|
|
285
|
+
Transforms a regular Python class into a PlexSpaces actor with:
|
|
286
|
+
- Automatic state persistence (via state() fields)
|
|
287
|
+
- Message routing (via @handler decorators)
|
|
288
|
+
- JSON serialization/deserialization
|
|
289
|
+
|
|
290
|
+
Args:
|
|
291
|
+
cls: The class to transform into an actor
|
|
292
|
+
facets: Optional list of facets this actor expects (e.g., ["durability", "registry"]).
|
|
293
|
+
For WASM actors, "durability" means checkpoint-based persistence is enabled
|
|
294
|
+
via WasmConfig.durability_enabled in the node/release config.
|
|
295
|
+
|
|
296
|
+
Returns:
|
|
297
|
+
The actor class with PlexSpaces metadata
|
|
298
|
+
|
|
299
|
+
Example:
|
|
300
|
+
@actor
|
|
301
|
+
class BankAccount:
|
|
302
|
+
balance: int = state(default=0)
|
|
303
|
+
|
|
304
|
+
@handler("deposit")
|
|
305
|
+
def deposit(self, amount: int) -> dict:
|
|
306
|
+
self.balance += amount
|
|
307
|
+
return {"balance": self.balance}
|
|
308
|
+
|
|
309
|
+
# With facets (for documentation and config validation):
|
|
310
|
+
@actor(facets=["durability"])
|
|
311
|
+
class DurableAccount:
|
|
312
|
+
balance: int = state(default=0)
|
|
313
|
+
"""
|
|
314
|
+
def decorator(cls: Type[T]) -> Type[T]:
|
|
315
|
+
return _actor_with_behavior(cls, BEHAVIOR_GEN_SERVER, facets)
|
|
316
|
+
|
|
317
|
+
# Handle both @actor and @actor(facets=[...]) syntax
|
|
318
|
+
if cls is not None:
|
|
319
|
+
return decorator(cls)
|
|
320
|
+
return decorator
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def event_actor(
|
|
324
|
+
cls: Type[T] = None,
|
|
325
|
+
*,
|
|
326
|
+
facets: Optional[List[str]] = None
|
|
327
|
+
) -> Union[Type[T], Callable[[Type[T]], Type[T]]]:
|
|
328
|
+
"""
|
|
329
|
+
Decorator for GenEvent-style actors (fire-and-forget event handling).
|
|
330
|
+
|
|
331
|
+
Like Erlang gen_event: handlers process events asynchronously; no request-reply.
|
|
332
|
+
Use @handler("event_name") for event types. Good for telemetry, logs, side effects.
|
|
333
|
+
|
|
334
|
+
Args:
|
|
335
|
+
cls: The class to transform into an actor
|
|
336
|
+
facets: Optional list of facets this actor expects (e.g., ["durability"])
|
|
337
|
+
|
|
338
|
+
Example:
|
|
339
|
+
@event_actor
|
|
340
|
+
class SensorStream:
|
|
341
|
+
@handler("ingest")
|
|
342
|
+
def ingest(self, sensor_id: str, value: str) -> str:
|
|
343
|
+
...
|
|
344
|
+
"""
|
|
345
|
+
def decorator(cls: Type[T]) -> Type[T]:
|
|
346
|
+
return _actor_with_behavior(cls, BEHAVIOR_GEN_EVENT, facets)
|
|
347
|
+
|
|
348
|
+
if cls is not None:
|
|
349
|
+
return decorator(cls)
|
|
350
|
+
return decorator
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def gen_server_actor(
|
|
354
|
+
cls: Type[T] = None,
|
|
355
|
+
*,
|
|
356
|
+
facets: Optional[List[str]] = None
|
|
357
|
+
) -> Union[Type[T], Callable[[Type[T]], Type[T]]]:
|
|
358
|
+
"""
|
|
359
|
+
Decorator for GenServer-style actors (request-reply).
|
|
360
|
+
|
|
361
|
+
Like Erlang gen_server: handlers return a reply to the caller.
|
|
362
|
+
Use @handler("call", "call") or GET for read handlers so client gets reply.
|
|
363
|
+
|
|
364
|
+
Args:
|
|
365
|
+
cls: The class to transform into an actor
|
|
366
|
+
facets: Optional list of facets this actor expects (e.g., ["durability"])
|
|
367
|
+
"""
|
|
368
|
+
def decorator(cls: Type[T]) -> Type[T]:
|
|
369
|
+
return _actor_with_behavior(cls, BEHAVIOR_GEN_SERVER, facets)
|
|
370
|
+
|
|
371
|
+
if cls is not None:
|
|
372
|
+
return decorator(cls)
|
|
373
|
+
return decorator
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def fsm_actor(
|
|
377
|
+
cls: Type[T] = None,
|
|
378
|
+
*,
|
|
379
|
+
facets: Optional[List[str]] = None,
|
|
380
|
+
states: Optional[List[str]] = None,
|
|
381
|
+
initial: Optional[str] = None,
|
|
382
|
+
) -> Union[Type[T], Callable[[Type[T]], Type[T]]]:
|
|
383
|
+
"""
|
|
384
|
+
Decorator for FSM-style actors (GenStateMachine).
|
|
385
|
+
|
|
386
|
+
Like Erlang gen_statem: stateful transitions; handlers can return next state.
|
|
387
|
+
|
|
388
|
+
Use this decorator for actors with well-defined state transitions. Define
|
|
389
|
+
valid transitions and use @handler for each transition trigger.
|
|
390
|
+
|
|
391
|
+
Args:
|
|
392
|
+
cls: The class to transform into an actor
|
|
393
|
+
facets: Optional list of facets this actor expects (e.g., ["durability"])
|
|
394
|
+
states: Optional list of valid state names for self-documentation and startup validation
|
|
395
|
+
initial: Optional initial state name; sets fsm_state field on construction if provided
|
|
396
|
+
|
|
397
|
+
Example:
|
|
398
|
+
@fsm_actor(states=["idle", "pending", "complete"], initial="idle")
|
|
399
|
+
class OrderFSM:
|
|
400
|
+
fsm_state: str = "idle"
|
|
401
|
+
|
|
402
|
+
@handler("create")
|
|
403
|
+
def create_order(self, order_id: str) -> dict:
|
|
404
|
+
if self.fsm_state != "idle":
|
|
405
|
+
return {"error": "must_be_idle"}
|
|
406
|
+
self.fsm_state = "pending"
|
|
407
|
+
return {"state": "pending"}
|
|
408
|
+
"""
|
|
409
|
+
def decorator(cls: Type[T]) -> Type[T]:
|
|
410
|
+
decorated = _actor_with_behavior(cls, BEHAVIOR_GEN_STATE_MACHINE, facets)
|
|
411
|
+
if states is not None:
|
|
412
|
+
decorated.__fsm_states__ = list(states)
|
|
413
|
+
if initial is not None:
|
|
414
|
+
decorated.__fsm_initial__ = initial
|
|
415
|
+
# Set fsm_state default if not already defined on the class
|
|
416
|
+
if not hasattr(decorated, 'fsm_state'):
|
|
417
|
+
decorated.fsm_state = initial
|
|
418
|
+
return decorated
|
|
419
|
+
|
|
420
|
+
if cls is not None:
|
|
421
|
+
return decorator(cls)
|
|
422
|
+
return decorator
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
def workflow_actor(
|
|
426
|
+
cls: Type[T] = None,
|
|
427
|
+
*,
|
|
428
|
+
facets: Optional[List[str]] = None
|
|
429
|
+
) -> Union[Type[T], Callable[[Type[T]], Type[T]]]:
|
|
430
|
+
"""
|
|
431
|
+
Decorator for workflow/orchestration actors.
|
|
432
|
+
|
|
433
|
+
Multi-step flows with durable state. Use this for long-running workflows
|
|
434
|
+
that need checkpointing and recovery.
|
|
435
|
+
|
|
436
|
+
Args:
|
|
437
|
+
cls: The class to transform into an actor
|
|
438
|
+
facets: Optional list of facets this actor expects (e.g., ["durability"])
|
|
439
|
+
"""
|
|
440
|
+
def decorator(cls: Type[T]) -> Type[T]:
|
|
441
|
+
return _actor_with_behavior(cls, BEHAVIOR_WORKFLOW, facets)
|
|
442
|
+
|
|
443
|
+
if cls is not None:
|
|
444
|
+
return decorator(cls)
|
|
445
|
+
return decorator
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
# ============================================================================
|
|
449
|
+
# Runtime Support (used by generated wrapper)
|
|
450
|
+
# ============================================================================
|
|
451
|
+
|
|
452
|
+
def get_state_dict(instance: Any) -> Dict[str, Any]:
|
|
453
|
+
"""Extract state fields from an actor instance as a dict."""
|
|
454
|
+
if not hasattr(instance, '_plexspaces_state_fields'):
|
|
455
|
+
return {}
|
|
456
|
+
|
|
457
|
+
state_dict = {}
|
|
458
|
+
for name, field in instance._plexspaces_state_fields.items():
|
|
459
|
+
state_dict[name] = getattr(instance, name)
|
|
460
|
+
return state_dict
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
def set_state_dict(instance: Any, state_dict: Dict[str, Any]) -> None:
|
|
464
|
+
"""Restore state fields on an actor instance from a dict."""
|
|
465
|
+
if not hasattr(instance, '_plexspaces_state_fields'):
|
|
466
|
+
return
|
|
467
|
+
|
|
468
|
+
for name in instance._plexspaces_state_fields:
|
|
469
|
+
if name in state_dict:
|
|
470
|
+
setattr(instance, name, state_dict[name])
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
def _has_float(obj: Any) -> bool:
|
|
474
|
+
"""Return True if obj (or nested dict/list) contains a float. Used to skip sanitize when safe."""
|
|
475
|
+
if isinstance(obj, float):
|
|
476
|
+
return True
|
|
477
|
+
if isinstance(obj, dict):
|
|
478
|
+
return any(_has_float(v) for v in obj.values())
|
|
479
|
+
if isinstance(obj, list):
|
|
480
|
+
return any(_has_float(v) for v in obj)
|
|
481
|
+
return False
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
def payload_from_request_json(payload_json: str) -> Dict[str, Any]:
|
|
485
|
+
"""Parse request body JSON; on 'Extra data' (concatenated JSON), use only the first object.
|
|
486
|
+
Avoids JSONDecodeError when the client or gateway sends multiple JSON values concatenated."""
|
|
487
|
+
if not payload_json or not payload_json.strip():
|
|
488
|
+
return {}
|
|
489
|
+
try:
|
|
490
|
+
return json.loads(payload_json)
|
|
491
|
+
except json.JSONDecodeError as e:
|
|
492
|
+
if "Extra data" in str(e):
|
|
493
|
+
try:
|
|
494
|
+
obj, _ = json.JSONDecoder().raw_decode(payload_json)
|
|
495
|
+
return obj if isinstance(obj, dict) else {}
|
|
496
|
+
except json.JSONDecodeError:
|
|
497
|
+
raise e
|
|
498
|
+
raise e
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
def _sanitize_payload_for_wasm(obj: Any) -> Any:
|
|
502
|
+
"""Recursively convert float to str in dicts/lists so WASM/componentize-py does not trap.
|
|
503
|
+
Note: str(float) can trap in componentize-py WASM; clients should send numbers as strings.
|
|
504
|
+
Creating new dicts/lists in sanitize can trigger WASM frame-cleanup trap on return; use
|
|
505
|
+
_has_float() to skip sanitize when result has no floats."""
|
|
506
|
+
if isinstance(obj, dict):
|
|
507
|
+
return {k: _sanitize_payload_for_wasm(v) for k, v in obj.items()}
|
|
508
|
+
if isinstance(obj, list):
|
|
509
|
+
return [_sanitize_payload_for_wasm(v) for v in obj]
|
|
510
|
+
if isinstance(obj, float):
|
|
511
|
+
return str(obj)
|
|
512
|
+
return obj
|
|
513
|
+
|
|
514
|
+
|
|
515
|
+
def _desanitize_from_wasm(obj: Any) -> Any:
|
|
516
|
+
"""Recursively restore stringified numbers back to numeric types.
|
|
517
|
+
Reverses _sanitize_payload_for_wasm: strings that look like numbers become int or float.
|
|
518
|
+
Applied to state dicts loaded via set_state so arithmetic operations work correctly."""
|
|
519
|
+
if isinstance(obj, dict):
|
|
520
|
+
return {k: _desanitize_from_wasm(v) for k, v in obj.items()}
|
|
521
|
+
if isinstance(obj, list):
|
|
522
|
+
return [_desanitize_from_wasm(v) for v in obj]
|
|
523
|
+
if isinstance(obj, str):
|
|
524
|
+
# Try int first (stricter), then float
|
|
525
|
+
try:
|
|
526
|
+
int_val = int(obj)
|
|
527
|
+
# Only convert if the string is exactly the int representation
|
|
528
|
+
# (avoids converting "1.0" to int 1)
|
|
529
|
+
if str(int_val) == obj:
|
|
530
|
+
return int_val
|
|
531
|
+
except (ValueError, TypeError):
|
|
532
|
+
pass
|
|
533
|
+
try:
|
|
534
|
+
return float(obj)
|
|
535
|
+
except (ValueError, TypeError):
|
|
536
|
+
pass
|
|
537
|
+
return obj
|
|
538
|
+
|
|
539
|
+
|
|
540
|
+
def dispatch_message(instance: Any, from_actor: str, msg_type: str, payload: Dict[str, Any]) -> Any:
|
|
541
|
+
"""
|
|
542
|
+
Dispatch a message to the appropriate handler.
|
|
543
|
+
|
|
544
|
+
Ask vs Tell (GET vs POST):
|
|
545
|
+
- GET requests use ask (request-reply): msg_type is "call", payload is query params as JSON.
|
|
546
|
+
Use GET for read handlers (e.g. get_readings, count) so the client receives the reply.
|
|
547
|
+
- POST/PUT/DELETE use tell (fire-and-forget): msg_type is "cast", payload is request body.
|
|
548
|
+
Use POST for write handlers (e.g. ingest, clear); reply is not sent to the client.
|
|
549
|
+
|
|
550
|
+
Payload key for operation (consistent across SDKs): canonical key is message_type; aliases op and msg_type.
|
|
551
|
+
Resolved in order: message_type -> op -> msg_type. E.g. {"message_type": "workflow_run"} or {"op": "deposit", "amount": 100}.
|
|
552
|
+
|
|
553
|
+
Returns the handler's return value (sent back to client for ask/GET), or an error dict if no handler found.
|
|
554
|
+
"""
|
|
555
|
+
handlers = getattr(instance, '_plexspaces_handlers', {})
|
|
556
|
+
effective_type = msg_type
|
|
557
|
+
handler_payload = payload
|
|
558
|
+
|
|
559
|
+
# Resolve operation from payload when envelope is cast/call or unknown: message_type (canonical) -> op -> msg_type
|
|
560
|
+
if isinstance(payload, dict):
|
|
561
|
+
if msg_type in ("cast", "call") or msg_type not in handlers:
|
|
562
|
+
for key in ("message_type", "op", "msg_type"):
|
|
563
|
+
val = payload.get(key)
|
|
564
|
+
if not val or (key == "op" and val in ("call", "cast")):
|
|
565
|
+
continue
|
|
566
|
+
effective_type = val
|
|
567
|
+
handler_payload = payload.get("payload", payload) if isinstance(payload.get("payload"), dict) else payload
|
|
568
|
+
break
|
|
569
|
+
if payload.get("payload") and isinstance(payload.get("payload"), dict) and effective_type == msg_type and msg_type in ("cast", "call"):
|
|
570
|
+
handler_payload = payload.get("payload")
|
|
571
|
+
|
|
572
|
+
def _filter_kwargs(method: Callable, data: Dict[str, Any]) -> Dict[str, Any]:
|
|
573
|
+
"""Keep only keys that match the method's signature (excluding self)."""
|
|
574
|
+
try:
|
|
575
|
+
sig = inspect.signature(method)
|
|
576
|
+
param_names = {
|
|
577
|
+
n for n in sig.parameters
|
|
578
|
+
if n != "self" and sig.parameters[n].kind
|
|
579
|
+
in (inspect.Parameter.POSITIONAL_OR_KEYWORD, inspect.Parameter.KEYWORD_ONLY)
|
|
580
|
+
}
|
|
581
|
+
return {k: v for k, v in data.items() if k in param_names}
|
|
582
|
+
except Exception:
|
|
583
|
+
return data
|
|
584
|
+
|
|
585
|
+
# Make from_actor available to handlers that declare a `from_actor` parameter.
|
|
586
|
+
# This lets handlers log/use the sender identity for request/reply debugging.
|
|
587
|
+
def _inject_from_actor(method: Callable, kwargs: Dict[str, Any]) -> Dict[str, Any]:
|
|
588
|
+
if from_actor:
|
|
589
|
+
try:
|
|
590
|
+
sig = inspect.signature(method)
|
|
591
|
+
if "from_actor" in sig.parameters and "from_actor" not in kwargs:
|
|
592
|
+
kwargs["from_actor"] = from_actor
|
|
593
|
+
except Exception:
|
|
594
|
+
pass
|
|
595
|
+
return kwargs
|
|
596
|
+
|
|
597
|
+
# Workflow behavior: route workflow_run / workflow_signal:name / workflow_query:name to run/signal/query (aligned with Rust Workflow trait)
|
|
598
|
+
if getattr(instance, "_plexspaces_workflow", False):
|
|
599
|
+
if effective_type == "workflow_run":
|
|
600
|
+
run_handler_method = getattr(instance, "_plexspaces_run_handler", None)
|
|
601
|
+
run_fn = (
|
|
602
|
+
getattr(instance, run_handler_method.__name__, None)
|
|
603
|
+
if callable(run_handler_method)
|
|
604
|
+
else None
|
|
605
|
+
) or getattr(instance, "run", None)
|
|
606
|
+
if callable(run_fn):
|
|
607
|
+
workflow_payload = handler_payload if isinstance(handler_payload, dict) else payload
|
|
608
|
+
if run_fn == getattr(instance, "run", None):
|
|
609
|
+
result = run_fn(workflow_payload)
|
|
610
|
+
else:
|
|
611
|
+
kwargs = _filter_kwargs(run_fn, workflow_payload if isinstance(workflow_payload, dict) else {})
|
|
612
|
+
kwargs = _inject_from_actor(run_fn, kwargs)
|
|
613
|
+
result = run_fn(**kwargs) if kwargs else run_fn()
|
|
614
|
+
return result if result is not None else {}
|
|
615
|
+
return {"error": "Workflow actor must implement run(payload)"}
|
|
616
|
+
if effective_type.startswith("workflow_signal:"):
|
|
617
|
+
signal_name = effective_type[len("workflow_signal:"):].strip()
|
|
618
|
+
signal_handler_method = getattr(instance, "_plexspaces_signal_handlers", {}).get(signal_name)
|
|
619
|
+
signal_fn = (
|
|
620
|
+
getattr(instance, signal_handler_method.__name__, None)
|
|
621
|
+
if callable(signal_handler_method)
|
|
622
|
+
else None
|
|
623
|
+
) or getattr(instance, "signal", None)
|
|
624
|
+
if callable(signal_fn):
|
|
625
|
+
workflow_payload = handler_payload if isinstance(handler_payload, dict) else payload
|
|
626
|
+
if signal_fn == getattr(instance, "signal", None):
|
|
627
|
+
signal_fn(signal_name, workflow_payload)
|
|
628
|
+
else:
|
|
629
|
+
kwargs = _filter_kwargs(signal_fn, workflow_payload if isinstance(workflow_payload, dict) else {})
|
|
630
|
+
kwargs = _inject_from_actor(signal_fn, kwargs)
|
|
631
|
+
signal_fn(**kwargs)
|
|
632
|
+
return {}
|
|
633
|
+
return {"error": "Workflow actor must implement signal(name, data)"}
|
|
634
|
+
if effective_type.startswith("workflow_query:"):
|
|
635
|
+
query_name = effective_type[len("workflow_query:"):].strip()
|
|
636
|
+
query_handler_method = getattr(instance, "_plexspaces_query_handlers", {}).get(query_name)
|
|
637
|
+
query_fn = (
|
|
638
|
+
getattr(instance, query_handler_method.__name__, None)
|
|
639
|
+
if callable(query_handler_method)
|
|
640
|
+
else None
|
|
641
|
+
) or getattr(instance, "query", None)
|
|
642
|
+
if callable(query_fn):
|
|
643
|
+
workflow_payload = handler_payload if isinstance(handler_payload, dict) else payload
|
|
644
|
+
if query_fn == getattr(instance, "query", None):
|
|
645
|
+
result = query_fn(query_name, workflow_payload)
|
|
646
|
+
else:
|
|
647
|
+
kwargs = _filter_kwargs(query_fn, workflow_payload if isinstance(workflow_payload, dict) else {})
|
|
648
|
+
kwargs = _inject_from_actor(query_fn, kwargs)
|
|
649
|
+
result = query_fn(**kwargs) if kwargs else query_fn()
|
|
650
|
+
return result if result is not None else {}
|
|
651
|
+
return {"error": "Workflow actor must implement query(name, params)"}
|
|
652
|
+
|
|
653
|
+
# Check for exact match
|
|
654
|
+
if effective_type in handlers:
|
|
655
|
+
handler_method = handlers[effective_type]
|
|
656
|
+
kwargs = _filter_kwargs(handler_method, handler_payload)
|
|
657
|
+
kwargs = _inject_from_actor(handler_method, kwargs)
|
|
658
|
+
result = handler_method(instance, **kwargs)
|
|
659
|
+
return result if result is not None else {}
|
|
660
|
+
if msg_type in handlers:
|
|
661
|
+
handler_method = handlers[msg_type]
|
|
662
|
+
kwargs = _filter_kwargs(handler_method, payload)
|
|
663
|
+
kwargs = _inject_from_actor(handler_method, kwargs)
|
|
664
|
+
result = handler_method(instance, **kwargs)
|
|
665
|
+
return result if result is not None else {}
|
|
666
|
+
|
|
667
|
+
# Check for "call" or "get_state" which might return state
|
|
668
|
+
if effective_type in ("call", "get_state") or msg_type in ("call", "get_state"):
|
|
669
|
+
return get_state_dict(instance)
|
|
670
|
+
|
|
671
|
+
return {"error": f"Unknown message type: {effective_type}"}
|
|
672
|
+
|
|
673
|
+
|
|
674
|
+
def init_actor(instance: Any, config: Dict[str, Any]) -> None:
|
|
675
|
+
"""
|
|
676
|
+
Initialize an actor instance with config.
|
|
677
|
+
|
|
678
|
+
If an @init_handler is defined, calls it with config.
|
|
679
|
+
Otherwise, sets config values directly on matching state fields.
|
|
680
|
+
"""
|
|
681
|
+
init_handler_method = getattr(instance, '_plexspaces_init_handler', None)
|
|
682
|
+
|
|
683
|
+
if init_handler_method is not None:
|
|
684
|
+
# The method is already bound to instance via getattr, so just pass config
|
|
685
|
+
init_handler_method(config)
|
|
686
|
+
else:
|
|
687
|
+
# Set config values on state fields
|
|
688
|
+
state_fields = getattr(instance, '_plexspaces_state_fields', {})
|
|
689
|
+
for key, value in config.items():
|
|
690
|
+
if key in state_fields:
|
|
691
|
+
setattr(instance, key, value)
|
|
File without changes
|
|
File without changes
|