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
plexspaces/runtime.py ADDED
@@ -0,0 +1,284 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-or-later
2
+ # Copyright (C) 2025 PlexSpaces Contributors
3
+ #
4
+ # PlexSpaces Runtime
5
+ #
6
+ # Generates the WIT interface wrapper for actor classes decorated with @actor.
7
+ # This bridges the gap between the Pythonic SDK API and the componentize-py
8
+ # WIT bindings.
9
+
10
+ """
11
+ Runtime support for PlexSpaces actors.
12
+
13
+ ## Multi-Actor Dispatch
14
+
15
+ The framework always sets ``actor_type`` to the exact class name in the init
16
+ config (e.g. ``"SessionActor"``). The dispatch rule is therefore simple:
17
+
18
+ 1. ``config["actor_type"]`` — exact lookup in the class map. This is the
19
+ normal case and covers every virtual-actor activation.
20
+ 2. ``config["role"]`` — exact lookup, used only when multiple children in
21
+ ``app-config.toml`` share the same ``actor_type`` but need different
22
+ behaviour (e.g. ``ephemeral`` / ``channel`` / ``controller`` all map to
23
+ ``AbstractionsActor``).
24
+ 3. ``default_class`` — first class in ``__all__`` / registration order.
25
+
26
+ No aliases, no prefix matching, no normalization. The framework is the
27
+ source of truth; the SDK trusts what it receives.
28
+ """
29
+
30
+ import json
31
+ from typing import Type, List, Dict, Optional
32
+
33
+ from .decorators import (
34
+ get_state_dict,
35
+ set_state_dict,
36
+ dispatch_message,
37
+ init_actor,
38
+ _sanitize_payload_for_wasm,
39
+ _desanitize_from_wasm,
40
+ _has_float,
41
+ )
42
+
43
+
44
+ def build_class_map(
45
+ actor_classes: List[Type],
46
+ actor_roles: Optional[Dict[str, Type]] = None,
47
+ ) -> Dict[str, Type]:
48
+ """Build the dispatch map used by ``select_actor_class``.
49
+
50
+ Keys are exact strings the framework places in ``actor_type`` or ``role``:
51
+ - Each class is registered under its exact ``__name__``.
52
+ - Explicit ``actor_roles`` entries are registered under their exact key
53
+ (for same-class multi-instance cases like ``ephemeral``/``channel``).
54
+ """
55
+ class_map: Dict[str, Type] = {}
56
+
57
+ # Class names first (lower priority — roles may override for same-class dispatch)
58
+ for cls in actor_classes:
59
+ class_map[cls.__name__] = cls
60
+
61
+ # Explicit role overrides (only valid when multiple children share actor_type)
62
+ if actor_roles:
63
+ for role, cls in actor_roles.items():
64
+ if cls in actor_classes:
65
+ class_map[role] = cls
66
+
67
+ return class_map
68
+
69
+
70
+ def select_actor_class(
71
+ config: dict,
72
+ class_map: Dict[str, Type],
73
+ default_class: Type,
74
+ ) -> Type:
75
+ """Select the actor class from the framework-supplied init config.
76
+
77
+ Priority:
78
+ 1. ``config["actor_type"]`` — exact match (the normal case).
79
+ 2. ``config["role"]`` — exact match (same-class multi-instance only).
80
+ 3. ``default_class`` — first registered class.
81
+ """
82
+ if not isinstance(config, dict) or not class_map:
83
+ return default_class
84
+
85
+ actor_type = config.get("actor_type", "")
86
+ if actor_type and actor_type in class_map:
87
+ return class_map[actor_type]
88
+
89
+ role = config.get("role", "")
90
+ if role and role in class_map:
91
+ return class_map[role]
92
+
93
+ return default_class
94
+
95
+
96
+ def generate_wrapper(
97
+ actor_classes: List[Type],
98
+ module_name: str,
99
+ actor_roles: Optional[Dict[str, Type]] = None,
100
+ ) -> str:
101
+ """Generate the WIT interface wrapper for one or more actor classes.
102
+
103
+ Args:
104
+ actor_classes: List of @actor decorated classes (at least one).
105
+ module_name: Name of the module containing the actor classes.
106
+ actor_roles: Optional role→class map for same-class multi-instance dispatch.
107
+
108
+ Returns:
109
+ Python source code for the generated wrapper.
110
+ """
111
+ if not actor_classes:
112
+ raise ValueError("No @actor classes provided")
113
+
114
+ class_names = [cls.__name__ for cls in actor_classes]
115
+ imports = ", ".join(class_names)
116
+ default_class = class_names[0]
117
+ class_map = build_class_map(actor_classes, actor_roles)
118
+
119
+ lines = [
120
+ "# AUTO-GENERATED by PlexSpaces SDK",
121
+ "# DO NOT EDIT - regenerate with: plexspaces-py build",
122
+ "",
123
+ "import json",
124
+ "from wit_world import exports",
125
+ f"from {module_name} import {imports}",
126
+ "from plexspaces.decorators import (",
127
+ " get_state_dict, set_state_dict, dispatch_message, init_actor,",
128
+ " _sanitize_payload_for_wasm, _desanitize_from_wasm, _has_float,",
129
+ " payload_from_request_json,",
130
+ ")",
131
+ "from plexspaces.runtime import select_actor_class as _select_actor_class",
132
+ "",
133
+ ]
134
+
135
+ if len(actor_classes) == 1:
136
+ lines.append(f"_actor_class = {default_class}")
137
+ lines.append("_CLASS_MAP = {}")
138
+ else:
139
+ lines.append(f"_actor_class = {default_class} # default")
140
+ lines.append("")
141
+ lines.append("# Dispatch map: actor_type / role -> class (exact match only)")
142
+ lines.append("_CLASS_MAP = {")
143
+ for key, cls in class_map.items():
144
+ lines.append(f' "{key}": {cls.__name__},')
145
+ lines.append("}")
146
+
147
+ lines.extend([
148
+ "",
149
+ "# Actor instance (created on first init)",
150
+ "_instance = None",
151
+ "",
152
+ "",
153
+ "def _select_class(config):",
154
+ ' """Delegate to plexspaces.runtime.select_actor_class (single source of truth)."""',
155
+ f" return _select_actor_class(config, _CLASS_MAP, {default_class})",
156
+ "",
157
+ "",
158
+ f'class Actor(exports.Actor):',
159
+ f' """WIT interface wrapper (supports {", ".join(class_names)})."""',
160
+ "",
161
+ " def _decode(self, data):",
162
+ ' """Decode WIT payload (list<u8>) to str. Accepts bytes or str."""',
163
+ " if isinstance(data, (bytes, bytearray)):",
164
+ " return data.decode('utf-8')",
165
+ " return str(data) if data else ''",
166
+ "",
167
+ " def init(self, config):",
168
+ ' """Initialize the actor with optional config."""',
169
+ " global _instance, _actor_class",
170
+ " try:",
171
+ " config_json = self._decode(config)",
172
+ " config = json.loads(config_json) if config_json else {}",
173
+ " _actor_class = _select_class(config)",
174
+ " _instance = _actor_class()",
175
+ " if config:",
176
+ " init_actor(_instance, config)",
177
+ " except Exception as e:",
178
+ ' return f"ERROR: {e}"',
179
+ "",
180
+ " def handle(self, from_actor: str, msg_type: str, payload):",
181
+ ' """Handle incoming message."""',
182
+ " global _instance",
183
+ " if _instance is None:",
184
+ ' return json.dumps({"error": "Actor not initialized"}).encode("utf-8")',
185
+ "",
186
+ " try:",
187
+ " payload_json = self._decode(payload)",
188
+ ' payload = payload_from_request_json(payload_json or "")',
189
+ " result = dispatch_message(_instance, from_actor, msg_type, payload)",
190
+ "",
191
+ " if isinstance(result, str):",
192
+ ' return result.encode("utf-8")',
193
+ " # json.dumps handles floats natively as JSON numbers.",
194
+ " # Do NOT sanitize floats to strings here -- that corrupts",
195
+ " # inter-actor ask() responses (receivers get strings where",
196
+ " # they expect numbers, causing 'float + str' TypeError).",
197
+ " # Float sanitization is only needed in get_state() for",
198
+ " # checkpoint persistence (reversed by _desanitize_from_wasm",
199
+ " # in set_state).",
200
+ ' return json.dumps(result).encode("utf-8")',
201
+ " except Exception as e:",
202
+ ' return json.dumps({"error": str(e)}).encode("utf-8")',
203
+ "",
204
+ " def get_state(self):",
205
+ ' """Get actor state as JSON bytes."""',
206
+ " global _instance",
207
+ " if _instance is None:",
208
+ ' return b"{}"',
209
+ "",
210
+ " try:",
211
+ " state = get_state_dict(_instance)",
212
+ " state_safe = _sanitize_payload_for_wasm(state)",
213
+ ' return json.dumps(state_safe).encode("utf-8")',
214
+ " except Exception as e:",
215
+ ' return json.dumps({"error": str(e)}).encode("utf-8")',
216
+ "",
217
+ " def set_state(self, state_data):",
218
+ ' """Restore actor state from JSON bytes."""',
219
+ " global _instance",
220
+ " if _instance is None:",
221
+ " _instance = _actor_class()",
222
+ "",
223
+ " try:",
224
+ " state_json = self._decode(state_data)",
225
+ " state = json.loads(state_json)",
226
+ " # Recursively restore stringified numbers back to numeric types.",
227
+ " # get_state sanitizes floats to strings for WASM JSON safety;",
228
+ " # _desanitize_from_wasm reverses this for lists, dicts, and scalars.",
229
+ " state = _desanitize_from_wasm(state)",
230
+ " set_state_dict(_instance, state)",
231
+ ' return ""',
232
+ " except Exception as e:",
233
+ ' return f"ERROR: {e}"',
234
+ ])
235
+
236
+ return "\n".join(lines)
237
+
238
+
239
+ def create_actor_module(user_module_path: str, output_path: str = None) -> str:
240
+ """
241
+ Create a wrapped actor module from a user's actor file.
242
+
243
+ Args:
244
+ user_module_path: Path to the user's Python file with @actor class(es)
245
+ output_path: Optional output path (defaults to {name}_wrapper.py)
246
+
247
+ Returns:
248
+ Path to the generated wrapper file
249
+ """
250
+ import importlib.util
251
+ import os
252
+
253
+ # Load the user's module
254
+ module_name = os.path.splitext(os.path.basename(user_module_path))[0]
255
+ spec = importlib.util.spec_from_file_location(module_name, user_module_path)
256
+ module = importlib.util.module_from_spec(spec)
257
+ spec.loader.exec_module(module)
258
+
259
+ # Find ALL @actor decorated classes (preserving definition order)
260
+ actor_classes = []
261
+ for name in dir(module):
262
+ obj = getattr(module, name)
263
+ if isinstance(obj, type) and hasattr(obj, '_plexspaces_is_actor'):
264
+ actor_classes.append(obj)
265
+
266
+ if not actor_classes:
267
+ raise ValueError(f"No @actor decorated class found in {user_module_path}")
268
+
269
+ actor_roles = getattr(module, "ACTOR_ROLES", None)
270
+
271
+ # Generate wrapper
272
+ wrapper_code = generate_wrapper(actor_classes, module_name, actor_roles=actor_roles)
273
+
274
+ # Write to output file
275
+ if output_path is None:
276
+ output_path = os.path.join(
277
+ os.path.dirname(user_module_path),
278
+ f"{module_name}_actor.py"
279
+ )
280
+
281
+ with open(output_path, 'w') as f:
282
+ f.write(wrapper_code)
283
+
284
+ return output_path
plexspaces/workflow.py ADDED
@@ -0,0 +1,105 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-or-later
2
+ # Copyright (C) 2025 PlexSpaces Contributors
3
+ #
4
+ # Workflow retry helpers aligned with proto RetryConfig (plexspaces.workflow.v1.RetryConfig).
5
+ # Single run-with-config semantics: use default_retry_config() for one attempt;
6
+ # pass config with max_attempts > 1 for retries.
7
+
8
+ from typing import Any, Callable, Dict, List, Optional, TypeVar, Union
9
+
10
+ T = TypeVar("T")
11
+
12
+ # Use the proto-generated RetryConfig when available (native/server-side with betterproto).
13
+ # Falls back to a plain dataclass for WASM guest environments where generated/ is not bundled.
14
+ # The interface is identical in both cases: attribute access (cfg.max_attempts) works for
15
+ # both the betterproto dataclass and the fallback dataclass.
16
+ try:
17
+ # betterproto generates plexspaces/workflow/v1/__init__.py with RetryConfig as a dataclass
18
+ from plexspaces.generated.plexspaces.workflow.v1 import RetryConfig # type: ignore[import]
19
+ except ImportError:
20
+ from dataclasses import dataclass, field as _field
21
+
22
+ @dataclass
23
+ class RetryConfig: # type: ignore[no-redef]
24
+ """Fallback RetryConfig matching proto plexspaces.workflow.v1.RetryConfig fields."""
25
+ max_attempts: int = 1
26
+ initial_interval_ms: int = 100
27
+ backoff_rate: float = 2.0
28
+ max_interval_ms: int = 30000
29
+ retryable_errors: List[str] = _field(default_factory=list)
30
+
31
+ # RetryConfigDict is a type alias kept for backward compatibility with callers that pass dicts.
32
+ # New code should use RetryConfig directly (attribute access instead of dict key access).
33
+ RetryConfigDict = Dict[str, Any]
34
+ RetryConfigInput = Union[RetryConfig, RetryConfigDict]
35
+
36
+
37
+ def _to_retry_config(config: Optional[RetryConfigInput]) -> Optional[RetryConfig]:
38
+ """Normalize dict-or-proto input into the proto-shaped RetryConfig object."""
39
+ if config is None:
40
+ return None
41
+ if isinstance(config, dict):
42
+ return RetryConfig(
43
+ max_attempts=config.get("max_attempts", 0),
44
+ initial_interval_ms=config.get("initial_interval_ms", 0),
45
+ backoff_rate=config.get("backoff_rate", 0.0),
46
+ max_interval_ms=config.get("max_interval_ms", 0),
47
+ retryable_errors=list(config.get("retryable_errors", [])),
48
+ )
49
+ return config
50
+
51
+
52
+ def default_retry_config() -> RetryConfig:
53
+ """Default retry config as the canonical proto-shaped model."""
54
+ return RetryConfig(
55
+ max_attempts=1,
56
+ initial_interval_ms=100,
57
+ backoff_rate=2.0,
58
+ max_interval_ms=30000,
59
+ retryable_errors=[],
60
+ )
61
+
62
+
63
+ def _effective_max_attempts(config: Optional[RetryConfig]) -> int:
64
+ """Effective max attempts: 0 or unset means 1."""
65
+ if not config:
66
+ return 1
67
+ n = getattr(config, "max_attempts", 0)
68
+ return 1 if n == 0 else max(1, n)
69
+
70
+
71
+ def with_retry(
72
+ fn: Callable[[], T],
73
+ retry_config: Optional[RetryConfigInput] = None,
74
+ ) -> T:
75
+ """
76
+ Execute a step with retries. Uses RetryConfig. When retry_config is omitted or
77
+ max_attempts unset, 3 attempts. When max_attempts is 0 or 1, single attempt.
78
+ Aligns with Rust run(name, retry, op) and proto RetryConfig.
79
+
80
+ Args:
81
+ fn: Callable with no args that returns a value or raises.
82
+ retry_config: Optional dict with max_attempts, initial_interval_ms, etc.
83
+ Omitted => 3 attempts; max_attempts: 1 => one attempt.
84
+
85
+ Returns:
86
+ The result of fn() after a successful attempt.
87
+
88
+ Raises:
89
+ The last exception if all attempts fail.
90
+ """
91
+ normalized = _to_retry_config(retry_config)
92
+ has_max = normalized is not None and getattr(normalized, "max_attempts", 0) != 0
93
+ from_config = _effective_max_attempts(normalized) if has_max else 0
94
+ max_attempts = from_config if from_config > 0 else 3
95
+ last_error: Optional[Exception] = None
96
+ for attempt in range(1, max_attempts + 1):
97
+ try:
98
+ return fn()
99
+ except Exception as e:
100
+ last_error = e
101
+ if attempt == max_attempts:
102
+ raise
103
+ if last_error is not None:
104
+ raise last_error
105
+ raise RuntimeError("with_retry: no result and no error")