orca-runtime-python 0.1.16__py3-none-any.whl → 0.1.18__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.
- orca_runtime_python/__init__.py +5 -2
- orca_runtime_python/machine.py +77 -7
- orca_runtime_python/parser.py +48 -1
- orca_runtime_python/persistence.py +23 -1
- orca_runtime_python/types.py +1 -0
- {orca_runtime_python-0.1.16.dist-info → orca_runtime_python-0.1.18.dist-info}/METADATA +1 -1
- orca_runtime_python-0.1.18.dist-info/RECORD +12 -0
- orca_runtime_python-0.1.16.dist-info/RECORD +0 -12
- {orca_runtime_python-0.1.16.dist-info → orca_runtime_python-0.1.18.dist-info}/WHEEL +0 -0
- {orca_runtime_python-0.1.16.dist-info → orca_runtime_python-0.1.18.dist-info}/top_level.txt +0 -0
orca_runtime_python/__init__.py
CHANGED
|
@@ -25,11 +25,11 @@ from .bus import (
|
|
|
25
25
|
get_event_bus,
|
|
26
26
|
)
|
|
27
27
|
|
|
28
|
-
from .machine import OrcaMachine
|
|
28
|
+
from .machine import OrcaMachine, MachineNotActiveError, TransitionResult
|
|
29
29
|
|
|
30
30
|
from .parser import parse_orca_md, parse_orca_auto
|
|
31
31
|
|
|
32
|
-
from .persistence import PersistenceAdapter, FilePersistence
|
|
32
|
+
from .persistence import PersistenceAdapter, AsyncPersistenceAdapter, FilePersistence
|
|
33
33
|
|
|
34
34
|
from .logging import LogSink, FileSink, ConsoleSink, MultiSink
|
|
35
35
|
|
|
@@ -55,11 +55,14 @@ __all__ = [
|
|
|
55
55
|
"get_event_bus",
|
|
56
56
|
# Machine
|
|
57
57
|
"OrcaMachine",
|
|
58
|
+
"MachineNotActiveError",
|
|
59
|
+
"TransitionResult",
|
|
58
60
|
# Parser
|
|
59
61
|
"parse_orca_md",
|
|
60
62
|
"parse_orca_auto",
|
|
61
63
|
# Persistence
|
|
62
64
|
"PersistenceAdapter",
|
|
65
|
+
"AsyncPersistenceAdapter",
|
|
63
66
|
"FilePersistence",
|
|
64
67
|
# Logging
|
|
65
68
|
"LogSink",
|
orca_runtime_python/machine.py
CHANGED
|
@@ -34,6 +34,7 @@ from .types import (
|
|
|
34
34
|
InvokeDef,
|
|
35
35
|
)
|
|
36
36
|
from .bus import EventBus, Event, EventType, get_event_bus
|
|
37
|
+
from .persistence import PersistenceAdapter, AsyncPersistenceAdapter
|
|
37
38
|
|
|
38
39
|
|
|
39
40
|
# Type alias for transition callback
|
|
@@ -43,12 +44,24 @@ TransitionCallback = Callable[[StateValue, StateValue], Awaitable[None]]
|
|
|
43
44
|
ActionHandler = Callable[..., Any] # async (dict, dict|None) -> dict|None
|
|
44
45
|
|
|
45
46
|
|
|
47
|
+
class MachineNotActiveError(RuntimeError):
|
|
48
|
+
"""
|
|
49
|
+
Raised when send() is called on a machine that has not been started
|
|
50
|
+
or has already been stopped.
|
|
51
|
+
|
|
52
|
+
Always indicates a caller-side sequencing error. The fix is to ensure
|
|
53
|
+
start() or resume() completes before any send() call reaches this machine.
|
|
54
|
+
"""
|
|
55
|
+
pass
|
|
56
|
+
|
|
57
|
+
|
|
46
58
|
@dataclass
|
|
47
59
|
class TransitionResult:
|
|
48
60
|
"""Result of a transition attempt."""
|
|
49
61
|
taken: bool
|
|
50
62
|
from_state: str
|
|
51
63
|
to_state: str | None = None
|
|
64
|
+
to_state_leaf: str | None = None
|
|
52
65
|
guard_failed: bool = False
|
|
53
66
|
error: str | None = None
|
|
54
67
|
|
|
@@ -80,6 +93,8 @@ class OrcaMachine:
|
|
|
80
93
|
event_bus: EventBus | None = None,
|
|
81
94
|
context: dict[str, Any] | None = None,
|
|
82
95
|
on_transition: TransitionCallback | None = None,
|
|
96
|
+
persistence: PersistenceAdapter | AsyncPersistenceAdapter | None = None,
|
|
97
|
+
run_id: str | None = None,
|
|
83
98
|
):
|
|
84
99
|
self.definition = definition
|
|
85
100
|
self.event_bus = event_bus or get_event_bus()
|
|
@@ -92,6 +107,10 @@ class OrcaMachine:
|
|
|
92
107
|
self._action_handlers: dict[str, ActionHandler] = {}
|
|
93
108
|
self._timeout_task: asyncio.Task | None = None
|
|
94
109
|
|
|
110
|
+
# Persistence
|
|
111
|
+
self._persistence = persistence
|
|
112
|
+
self._run_id = run_id
|
|
113
|
+
|
|
95
114
|
# Child machine management
|
|
96
115
|
self._child_machines: dict[str, OrcaMachine] = {}
|
|
97
116
|
self._sibling_machines: dict[str, MachineDef] | None = None
|
|
@@ -120,14 +139,16 @@ class OrcaMachine:
|
|
|
120
139
|
def snapshot(self) -> dict[str, Any]:
|
|
121
140
|
"""
|
|
122
141
|
Capture the current machine state as a serializable snapshot.
|
|
123
|
-
The snapshot includes
|
|
124
|
-
Child machine snapshots are included.
|
|
142
|
+
The snapshot includes machine name, definition version, state value,
|
|
143
|
+
context, and timestamp. Child machine snapshots are included.
|
|
125
144
|
Action handlers are NOT included — re-register them after restore.
|
|
126
145
|
"""
|
|
127
146
|
import copy
|
|
128
147
|
import time
|
|
129
148
|
state_val = self._state.value
|
|
130
149
|
return {
|
|
150
|
+
"machine": self.definition.name,
|
|
151
|
+
"definition_version": self.definition.version,
|
|
131
152
|
"state": copy.deepcopy(state_val),
|
|
132
153
|
"context": copy.deepcopy(self.context),
|
|
133
154
|
"children": {k: m.snapshot() for k, m in self._child_machines.items()},
|
|
@@ -153,6 +174,29 @@ class OrcaMachine:
|
|
|
153
174
|
for leaf in self._state.leaves():
|
|
154
175
|
self._start_timeout_for_state(leaf)
|
|
155
176
|
|
|
177
|
+
async def load_or_start(self) -> None:
|
|
178
|
+
"""
|
|
179
|
+
If a snapshot exists for self._run_id, resume from it.
|
|
180
|
+
Otherwise, start fresh.
|
|
181
|
+
|
|
182
|
+
Replaces the manual pattern of checking persistence, then calling
|
|
183
|
+
resume() or start() explicitly.
|
|
184
|
+
"""
|
|
185
|
+
if self._persistence and self._run_id:
|
|
186
|
+
if asyncio.iscoroutinefunction(self._persistence.exists):
|
|
187
|
+
exists = await self._persistence.exists(self._run_id) # type: ignore[misc]
|
|
188
|
+
else:
|
|
189
|
+
exists = self._persistence.exists(self._run_id)
|
|
190
|
+
if exists:
|
|
191
|
+
if asyncio.iscoroutinefunction(self._persistence.load):
|
|
192
|
+
snap = await self._persistence.load(self._run_id) # type: ignore[misc]
|
|
193
|
+
else:
|
|
194
|
+
snap = self._persistence.load(self._run_id)
|
|
195
|
+
if snap:
|
|
196
|
+
await self.resume(snap)
|
|
197
|
+
return
|
|
198
|
+
await self.start()
|
|
199
|
+
|
|
156
200
|
async def resume(self, snap: dict[str, Any]) -> None:
|
|
157
201
|
"""
|
|
158
202
|
Boot the machine from a saved snapshot, skipping on_entry for the
|
|
@@ -167,6 +211,18 @@ class OrcaMachine:
|
|
|
167
211
|
if self._active:
|
|
168
212
|
return
|
|
169
213
|
|
|
214
|
+
snap_version = snap.get("definition_version", "0.1.0")
|
|
215
|
+
if snap_version != self.definition.version:
|
|
216
|
+
import warnings
|
|
217
|
+
warnings.warn(
|
|
218
|
+
f"Snapshot version '{snap_version}' does not match "
|
|
219
|
+
f"machine definition version '{self.definition.version}' "
|
|
220
|
+
f"for machine '{self.definition.name}'. "
|
|
221
|
+
"The snapshot may be incompatible with the current definition.",
|
|
222
|
+
UserWarning,
|
|
223
|
+
stacklevel=2,
|
|
224
|
+
)
|
|
225
|
+
|
|
170
226
|
self._state = StateValue(copy.deepcopy(snap["state"]))
|
|
171
227
|
self.context = copy.deepcopy(snap["context"])
|
|
172
228
|
self._active = True
|
|
@@ -310,10 +366,9 @@ class OrcaMachine:
|
|
|
310
366
|
TransitionResult indicating what happened
|
|
311
367
|
"""
|
|
312
368
|
if not self._active:
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
error="Machine is not active"
|
|
369
|
+
raise MachineNotActiveError(
|
|
370
|
+
f"Machine '{self.definition.name}' is not active. "
|
|
371
|
+
"Call start() or resume() before sending events."
|
|
317
372
|
)
|
|
318
373
|
|
|
319
374
|
# Normalize to Event, preserving the original event name
|
|
@@ -431,12 +486,25 @@ class OrcaMachine:
|
|
|
431
486
|
}
|
|
432
487
|
))
|
|
433
488
|
|
|
489
|
+
await self._auto_save()
|
|
490
|
+
|
|
434
491
|
return TransitionResult(
|
|
435
492
|
taken=True,
|
|
436
493
|
from_state=str(old_state),
|
|
437
|
-
to_state=str(self._state)
|
|
494
|
+
to_state=str(self._state),
|
|
495
|
+
to_state_leaf=self._state.leaf(),
|
|
438
496
|
)
|
|
439
497
|
|
|
498
|
+
async def _auto_save(self) -> None:
|
|
499
|
+
"""Save a snapshot if persistence and run_id are configured."""
|
|
500
|
+
if self._persistence is None or self._run_id is None:
|
|
501
|
+
return
|
|
502
|
+
snap = self.snapshot()
|
|
503
|
+
if asyncio.iscoroutinefunction(self._persistence.save):
|
|
504
|
+
await self._persistence.save(self._run_id, snap) # type: ignore[misc]
|
|
505
|
+
else:
|
|
506
|
+
self._persistence.save(self._run_id, snap)
|
|
507
|
+
|
|
440
508
|
def _find_event_type(self, event_name: str) -> EventType:
|
|
441
509
|
"""Map event name to EventType enum."""
|
|
442
510
|
# Build a mapping from event names to event types
|
|
@@ -830,6 +898,8 @@ class OrcaMachine:
|
|
|
830
898
|
}
|
|
831
899
|
))
|
|
832
900
|
|
|
901
|
+
await self._auto_save()
|
|
902
|
+
|
|
833
903
|
def _is_event_ignored(self, event_name: str) -> bool:
|
|
834
904
|
"""Check if event is explicitly ignored in current state or parent states."""
|
|
835
905
|
current = self._state.leaf()
|
orca_runtime_python/parser.py
CHANGED
|
@@ -650,9 +650,43 @@ def _map_op(op: str) -> str:
|
|
|
650
650
|
}.get(op, "eq")
|
|
651
651
|
|
|
652
652
|
|
|
653
|
+
def _validate_machine_def(defn: MachineDef) -> None:
|
|
654
|
+
"""Post-parse structural validation. Raises ParseError on invalid definitions."""
|
|
655
|
+
if not defn.states:
|
|
656
|
+
raise ParseError(f"Machine '{defn.name}': no states defined.")
|
|
657
|
+
if not any(s.is_initial for s in defn.states):
|
|
658
|
+
raise ParseError(f"Machine '{defn.name}': no [initial] state.")
|
|
659
|
+
state_names = {s.name for s in defn.states}
|
|
660
|
+
# Include nested/parallel child state names
|
|
661
|
+
def _collect_names(states: list[StateDef]) -> None:
|
|
662
|
+
for s in states:
|
|
663
|
+
state_names.add(s.name)
|
|
664
|
+
if s.contains:
|
|
665
|
+
_collect_names(s.contains)
|
|
666
|
+
if s.parallel:
|
|
667
|
+
for region in s.parallel.regions:
|
|
668
|
+
_collect_names(region.states)
|
|
669
|
+
_collect_names(defn.states)
|
|
670
|
+
for t in defn.transitions:
|
|
671
|
+
if t.source not in state_names:
|
|
672
|
+
raise ParseError(
|
|
673
|
+
f"Machine '{defn.name}': transition source '{t.source}' is not defined."
|
|
674
|
+
)
|
|
675
|
+
if t.target not in state_names:
|
|
676
|
+
raise ParseError(
|
|
677
|
+
f"Machine '{defn.name}': transition target '{t.target}' is not defined."
|
|
678
|
+
)
|
|
679
|
+
guard_ref = t.guard.lstrip("!") if t.guard else None
|
|
680
|
+
if guard_ref and guard_ref not in defn.guards:
|
|
681
|
+
raise ParseError(
|
|
682
|
+
f"Machine '{defn.name}': guard '{t.guard}' is used but not defined."
|
|
683
|
+
)
|
|
684
|
+
|
|
685
|
+
|
|
653
686
|
def _parse_machine_elements(elements: list[_MdElement]) -> MachineDef:
|
|
654
687
|
"""Parse a single machine from already-split elements."""
|
|
655
688
|
machine_name = "unknown"
|
|
689
|
+
machine_version = "0.1.0"
|
|
656
690
|
context: dict[str, Any] = {}
|
|
657
691
|
events: list[str] = []
|
|
658
692
|
transitions: list[Transition] = []
|
|
@@ -661,6 +695,7 @@ def _parse_machine_elements(elements: list[_MdElement]) -> MachineDef:
|
|
|
661
695
|
effects: list[EffectDef] = []
|
|
662
696
|
state_entries: list[_MdStateEntry] = []
|
|
663
697
|
current_state_entry: _MdStateEntry | None = None
|
|
698
|
+
in_machine_meta: bool = False
|
|
664
699
|
|
|
665
700
|
i = 0
|
|
666
701
|
while i < len(elements):
|
|
@@ -671,9 +706,13 @@ def _parse_machine_elements(elements: list[_MdElement]) -> MachineDef:
|
|
|
671
706
|
if el.level == 1 and el.text.startswith("machine "):
|
|
672
707
|
machine_name = el.text[8:].strip()
|
|
673
708
|
current_state_entry = None
|
|
709
|
+
in_machine_meta = True
|
|
674
710
|
i += 1
|
|
675
711
|
continue
|
|
676
712
|
|
|
713
|
+
# Section and state headings clear machine-meta mode
|
|
714
|
+
in_machine_meta = False
|
|
715
|
+
|
|
677
716
|
# Section headings
|
|
678
717
|
section_name = el.text.lower()
|
|
679
718
|
if section_name in ("context", "events", "transitions", "guards", "actions", "effects"):
|
|
@@ -807,6 +846,11 @@ def _parse_machine_elements(elements: list[_MdElement]) -> MachineDef:
|
|
|
807
846
|
elif isinstance(el, _MdBulletList):
|
|
808
847
|
for item in el.items:
|
|
809
848
|
_parse_md_state_bullet(current_state_entry, item)
|
|
849
|
+
elif in_machine_meta and isinstance(el, _MdBulletList):
|
|
850
|
+
# Machine-level metadata bullets (e.g. "- version: 1.2.0")
|
|
851
|
+
for item in el.items:
|
|
852
|
+
if item.startswith("version:"):
|
|
853
|
+
machine_version = item[len("version:"):].strip()
|
|
810
854
|
|
|
811
855
|
i += 1
|
|
812
856
|
|
|
@@ -814,7 +858,7 @@ def _parse_machine_elements(elements: list[_MdElement]) -> MachineDef:
|
|
|
814
858
|
base_level = state_entries[0].level if state_entries else 2
|
|
815
859
|
states, _ = _build_md_states_at_level(state_entries, 0, base_level)
|
|
816
860
|
|
|
817
|
-
|
|
861
|
+
defn = MachineDef(
|
|
818
862
|
name=machine_name,
|
|
819
863
|
context=context,
|
|
820
864
|
events=events,
|
|
@@ -823,7 +867,10 @@ def _parse_machine_elements(elements: list[_MdElement]) -> MachineDef:
|
|
|
823
867
|
guards=guards,
|
|
824
868
|
actions=actions,
|
|
825
869
|
effects=effects,
|
|
870
|
+
version=machine_version,
|
|
826
871
|
)
|
|
872
|
+
_validate_machine_def(defn)
|
|
873
|
+
return defn
|
|
827
874
|
|
|
828
875
|
|
|
829
876
|
def parse_orca_md(source: str) -> MachineDef:
|
|
@@ -22,7 +22,7 @@ from typing import Any, Protocol, runtime_checkable
|
|
|
22
22
|
@runtime_checkable
|
|
23
23
|
class PersistenceAdapter(Protocol):
|
|
24
24
|
"""
|
|
25
|
-
|
|
25
|
+
Synchronous persistence adapter. Suitable for local file I/O.
|
|
26
26
|
|
|
27
27
|
Implementations must support three operations keyed by run_id:
|
|
28
28
|
save, load (returns None if not found), and exists.
|
|
@@ -41,6 +41,28 @@ class PersistenceAdapter(Protocol):
|
|
|
41
41
|
...
|
|
42
42
|
|
|
43
43
|
|
|
44
|
+
@runtime_checkable
|
|
45
|
+
class AsyncPersistenceAdapter(Protocol):
|
|
46
|
+
"""
|
|
47
|
+
Async persistence adapter for database and network backends.
|
|
48
|
+
|
|
49
|
+
Drop-in replacement for PersistenceAdapter when using async drivers
|
|
50
|
+
(asyncpg, aioredis, aiobotocore, etc.). OrcaMachine accepts either.
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
async def save(self, run_id: str, snapshot: dict[str, Any]) -> None:
|
|
54
|
+
"""Persist snapshot under run_id. Overwrites any prior snapshot."""
|
|
55
|
+
...
|
|
56
|
+
|
|
57
|
+
async def load(self, run_id: str) -> dict[str, Any] | None:
|
|
58
|
+
"""Return the snapshot for run_id, or None if it doesn't exist."""
|
|
59
|
+
...
|
|
60
|
+
|
|
61
|
+
async def exists(self, run_id: str) -> bool:
|
|
62
|
+
"""Return True if a snapshot exists for run_id."""
|
|
63
|
+
...
|
|
64
|
+
|
|
65
|
+
|
|
44
66
|
class FilePersistence:
|
|
45
67
|
"""
|
|
46
68
|
File-based persistence adapter.
|
orca_runtime_python/types.py
CHANGED
|
@@ -99,6 +99,7 @@ class MachineDef:
|
|
|
99
99
|
guards: dict[str, GuardExpression] = field(default_factory=dict)
|
|
100
100
|
actions: list[ActionSignature] = field(default_factory=list)
|
|
101
101
|
effects: list[EffectDef] = field(default_factory=list)
|
|
102
|
+
version: str = "0.1.0"
|
|
102
103
|
|
|
103
104
|
|
|
104
105
|
class GuardExpression:
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
orca_runtime_python/__init__.py,sha256=wBa4uI4nU8-wafXM3rex4qHZdCqO24LtSqVGr_kSdMU,1289
|
|
2
|
+
orca_runtime_python/bus.py,sha256=kCdMwm3O97wlX5hr3_aK-YkhABO7Z0M-eaYPYJAIa9g,7171
|
|
3
|
+
orca_runtime_python/effects.py,sha256=eAkm2JdHSH5wUm8ZQjBf7ncgMI1iw7xq9bKi5X1AJEM,5649
|
|
4
|
+
orca_runtime_python/logging.py,sha256=Bnw_PCGlMQaDbti3UNMudhj0RcOckF_7YYt_5yWatGY,4360
|
|
5
|
+
orca_runtime_python/machine.py,sha256=tGcukHSCfBGKwJDxKO7vaU_dymCg9Zcpma--_KbBVvE,35972
|
|
6
|
+
orca_runtime_python/parser.py,sha256=CDhdzU_GgJneBhuNPGyF45Ywa_Mk9w0VVnmQobBSyCk,32709
|
|
7
|
+
orca_runtime_python/persistence.py,sha256=rjrgXDhfs3IA-c_xkQcFC4lCBlzmQtLL5lNWGu2kWu4,3330
|
|
8
|
+
orca_runtime_python/types.py,sha256=Sy1FUG9kCJREQLIWytsTw6uvPdpZOeR9b3bkSvXMzd4,7259
|
|
9
|
+
orca_runtime_python-0.1.18.dist-info/METADATA,sha256=ZJfXb6uyJKW-UCmedOhuXyesGPfOGQB0EXsR889GjnQ,7165
|
|
10
|
+
orca_runtime_python-0.1.18.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
11
|
+
orca_runtime_python-0.1.18.dist-info/top_level.txt,sha256=r7KIKsP_6Io6KiMqlBoNEc4IwzaE7O3VTG-PSEu0wVY,20
|
|
12
|
+
orca_runtime_python-0.1.18.dist-info/RECORD,,
|
|
@@ -1,12 +0,0 @@
|
|
|
1
|
-
orca_runtime_python/__init__.py,sha256=RgEnSjy5mJO_M6_3exaoWmmEK_5aXYPRMS82naWcebk,1139
|
|
2
|
-
orca_runtime_python/bus.py,sha256=kCdMwm3O97wlX5hr3_aK-YkhABO7Z0M-eaYPYJAIa9g,7171
|
|
3
|
-
orca_runtime_python/effects.py,sha256=eAkm2JdHSH5wUm8ZQjBf7ncgMI1iw7xq9bKi5X1AJEM,5649
|
|
4
|
-
orca_runtime_python/logging.py,sha256=Bnw_PCGlMQaDbti3UNMudhj0RcOckF_7YYt_5yWatGY,4360
|
|
5
|
-
orca_runtime_python/machine.py,sha256=6gccjFkD96Wpzo1VWXGTbS7gkcIzdu3w88ZTu1ey_C8,33126
|
|
6
|
-
orca_runtime_python/parser.py,sha256=wu9Dlv7O7xOurRr3--lO5X-v57LkVvkbtAfcc3ez8cg,30714
|
|
7
|
-
orca_runtime_python/persistence.py,sha256=lsQlvGXPelZ5u2Ote-OegtmE6Rk10fa-qj9Dkg-zl9E,2590
|
|
8
|
-
orca_runtime_python/types.py,sha256=YlYcyvGHI9myHq5XDinGTjPIKadyG4kuGQvMPtU0eCo,7232
|
|
9
|
-
orca_runtime_python-0.1.16.dist-info/METADATA,sha256=fTYGThJgak9lIPPP5Dq6CPWk_GXjbHNPojwfOeK7iKI,7165
|
|
10
|
-
orca_runtime_python-0.1.16.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
11
|
-
orca_runtime_python-0.1.16.dist-info/top_level.txt,sha256=r7KIKsP_6Io6KiMqlBoNEc4IwzaE7O3VTG-PSEu0wVY,20
|
|
12
|
-
orca_runtime_python-0.1.16.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|