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.
Files changed (87) hide show
  1. plexspaces/__init__.py +80 -0
  2. plexspaces/actor_id.py +114 -0
  3. plexspaces/decorators.py +691 -0
  4. plexspaces/generated/__init__.py +0 -0
  5. plexspaces/generated/buf/__init__.py +0 -0
  6. plexspaces/generated/buf/validate/__init__.py +3799 -0
  7. plexspaces/generated/google/__init__.py +0 -0
  8. plexspaces/generated/google/api/__init__.py +379 -0
  9. plexspaces/generated/grpc/__init__.py +0 -0
  10. plexspaces/generated/grpc/gateway/__init__.py +0 -0
  11. plexspaces/generated/grpc/gateway/protoc_gen_openapiv2/__init__.py +0 -0
  12. plexspaces/generated/grpc/gateway/protoc_gen_openapiv2/options/__init__.py +1022 -0
  13. plexspaces/generated/plexspaces/__init__.py +0 -0
  14. plexspaces/generated/plexspaces/actor/__init__.py +0 -0
  15. plexspaces/generated/plexspaces/actor/v1/__init__.py +3538 -0
  16. plexspaces/generated/plexspaces/application/__init__.py +0 -0
  17. plexspaces/generated/plexspaces/application/v1/__init__.py +827 -0
  18. plexspaces/generated/plexspaces/behaviors/__init__.py +0 -0
  19. plexspaces/generated/plexspaces/behaviors/v1/__init__.py +63 -0
  20. plexspaces/generated/plexspaces/channel/__init__.py +0 -0
  21. plexspaces/generated/plexspaces/channel/v1/__init__.py +915 -0
  22. plexspaces/generated/plexspaces/circuitbreaker/__init__.py +0 -0
  23. plexspaces/generated/plexspaces/circuitbreaker/prv/__init__.py +398 -0
  24. plexspaces/generated/plexspaces/common/__init__.py +0 -0
  25. plexspaces/generated/plexspaces/common/v1/__init__.py +691 -0
  26. plexspaces/generated/plexspaces/config/__init__.py +0 -0
  27. plexspaces/generated/plexspaces/config/v1/__init__.py +109 -0
  28. plexspaces/generated/plexspaces/dashboard/__init__.py +0 -0
  29. plexspaces/generated/plexspaces/dashboard/v1/__init__.py +647 -0
  30. plexspaces/generated/plexspaces/facets/__init__.py +0 -0
  31. plexspaces/generated/plexspaces/facets/v1/__init__.py +333 -0
  32. plexspaces/generated/plexspaces/firecracker/__init__.py +0 -0
  33. plexspaces/generated/plexspaces/firecracker/v1/__init__.py +890 -0
  34. plexspaces/generated/plexspaces/grpc/__init__.py +0 -0
  35. plexspaces/generated/plexspaces/grpc/v1/__init__.py +679 -0
  36. plexspaces/generated/plexspaces/journaling/__init__.py +0 -0
  37. plexspaces/generated/plexspaces/journaling/v1/__init__.py +1006 -0
  38. plexspaces/generated/plexspaces/keyvalue/__init__.py +0 -0
  39. plexspaces/generated/plexspaces/keyvalue/v1/__init__.py +1007 -0
  40. plexspaces/generated/plexspaces/locks/__init__.py +0 -0
  41. plexspaces/generated/plexspaces/locks/prv/__init__.py +122 -0
  42. plexspaces/generated/plexspaces/mailbox/__init__.py +0 -0
  43. plexspaces/generated/plexspaces/mailbox/v1/__init__.py +246 -0
  44. plexspaces/generated/plexspaces/metrics/__init__.py +0 -0
  45. plexspaces/generated/plexspaces/metrics/v1/__init__.py +878 -0
  46. plexspaces/generated/plexspaces/node/__init__.py +0 -0
  47. plexspaces/generated/plexspaces/node/v1/__init__.py +2564 -0
  48. plexspaces/generated/plexspaces/object_registry/__init__.py +0 -0
  49. plexspaces/generated/plexspaces/object_registry/v1/__init__.py +759 -0
  50. plexspaces/generated/plexspaces/persistence/__init__.py +0 -0
  51. plexspaces/generated/plexspaces/persistence/prv/__init__.py +539 -0
  52. plexspaces/generated/plexspaces/pool/__init__.py +0 -0
  53. plexspaces/generated/plexspaces/pool/v1/__init__.py +791 -0
  54. plexspaces/generated/plexspaces/processgroups/__init__.py +0 -0
  55. plexspaces/generated/plexspaces/processgroups/v1/__init__.py +622 -0
  56. plexspaces/generated/plexspaces/scheduling/__init__.py +0 -0
  57. plexspaces/generated/plexspaces/scheduling/v1/__init__.py +364 -0
  58. plexspaces/generated/plexspaces/security/__init__.py +0 -0
  59. plexspaces/generated/plexspaces/security/v1/__init__.py +254 -0
  60. plexspaces/generated/plexspaces/services/__init__.py +0 -0
  61. plexspaces/generated/plexspaces/services/prv/__init__.py +112 -0
  62. plexspaces/generated/plexspaces/storage/__init__.py +0 -0
  63. plexspaces/generated/plexspaces/storage/v1/__init__.py +747 -0
  64. plexspaces/generated/plexspaces/supervision/__init__.py +0 -0
  65. plexspaces/generated/plexspaces/supervision/v1/__init__.py +536 -0
  66. plexspaces/generated/plexspaces/system/__init__.py +0 -0
  67. plexspaces/generated/plexspaces/system/v1/__init__.py +1602 -0
  68. plexspaces/generated/plexspaces/timer/__init__.py +0 -0
  69. plexspaces/generated/plexspaces/timer/v1/__init__.py +402 -0
  70. plexspaces/generated/plexspaces/tuplespace/__init__.py +0 -0
  71. plexspaces/generated/plexspaces/tuplespace/v1/__init__.py +869 -0
  72. plexspaces/generated/plexspaces/wasm/__init__.py +0 -0
  73. plexspaces/generated/plexspaces/wasm/v1/__init__.py +920 -0
  74. plexspaces/generated/plexspaces/workflow/__init__.py +0 -0
  75. plexspaces/generated/plexspaces/workflow/v1/__init__.py +870 -0
  76. plexspaces/host.py +2093 -0
  77. plexspaces/leader_worker.py +148 -0
  78. plexspaces/proto_wire.py +1357 -0
  79. plexspaces/runtime.py +284 -0
  80. plexspaces/workflow.py +105 -0
  81. plexspaces-0.1.1.dist-info/METADATA +505 -0
  82. plexspaces-0.1.1.dist-info/RECORD +87 -0
  83. plexspaces-0.1.1.dist-info/WHEEL +5 -0
  84. plexspaces-0.1.1.dist-info/entry_points.txt +2 -0
  85. plexspaces-0.1.1.dist-info/top_level.txt +2 -0
  86. plexspaces_cli/__init__.py +8 -0
  87. plexspaces_cli/build.py +374 -0
@@ -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