orca-runtime-python 0.1.17__tar.gz → 0.1.19__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {orca_runtime_python-0.1.17 → orca_runtime_python-0.1.19}/PKG-INFO +1 -1
- {orca_runtime_python-0.1.17 → orca_runtime_python-0.1.19}/orca_runtime_python/__init__.py +5 -2
- {orca_runtime_python-0.1.17 → orca_runtime_python-0.1.19}/orca_runtime_python/machine.py +77 -7
- {orca_runtime_python-0.1.17 → orca_runtime_python-0.1.19}/orca_runtime_python/parser.py +48 -1
- {orca_runtime_python-0.1.17 → orca_runtime_python-0.1.19}/orca_runtime_python/persistence.py +23 -1
- {orca_runtime_python-0.1.17 → orca_runtime_python-0.1.19}/orca_runtime_python/types.py +1 -0
- {orca_runtime_python-0.1.17 → orca_runtime_python-0.1.19}/orca_runtime_python.egg-info/PKG-INFO +1 -1
- {orca_runtime_python-0.1.17 → orca_runtime_python-0.1.19}/orca_runtime_python.egg-info/SOURCES.txt +1 -0
- {orca_runtime_python-0.1.17 → orca_runtime_python-0.1.19}/pyproject.toml +1 -1
- orca_runtime_python-0.1.19/tests/test_production_hardening.py +391 -0
- {orca_runtime_python-0.1.17 → orca_runtime_python-0.1.19}/README.md +0 -0
- {orca_runtime_python-0.1.17 → orca_runtime_python-0.1.19}/orca_runtime_python/bus.py +0 -0
- {orca_runtime_python-0.1.17 → orca_runtime_python-0.1.19}/orca_runtime_python/effects.py +0 -0
- {orca_runtime_python-0.1.17 → orca_runtime_python-0.1.19}/orca_runtime_python/logging.py +0 -0
- {orca_runtime_python-0.1.17 → orca_runtime_python-0.1.19}/orca_runtime_python.egg-info/dependency_links.txt +0 -0
- {orca_runtime_python-0.1.17 → orca_runtime_python-0.1.19}/orca_runtime_python.egg-info/requires.txt +0 -0
- {orca_runtime_python-0.1.17 → orca_runtime_python-0.1.19}/orca_runtime_python.egg-info/top_level.txt +0 -0
- {orca_runtime_python-0.1.17 → orca_runtime_python-0.1.19}/setup.cfg +0 -0
- {orca_runtime_python-0.1.17 → orca_runtime_python-0.1.19}/tests/test_actions_timeouts.py +0 -0
- {orca_runtime_python-0.1.17 → orca_runtime_python-0.1.19}/tests/test_guards.py +0 -0
- {orca_runtime_python-0.1.17 → orca_runtime_python-0.1.19}/tests/test_ignored_events.py +0 -0
- {orca_runtime_python-0.1.17 → orca_runtime_python-0.1.19}/tests/test_md_parser.py +0 -0
- {orca_runtime_python-0.1.17 → orca_runtime_python-0.1.19}/tests/test_parallel.py +0 -0
- {orca_runtime_python-0.1.17 → orca_runtime_python-0.1.19}/tests/test_snapshot_restore.py +0 -0
|
@@ -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",
|
|
@@ -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()
|
|
@@ -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:
|
{orca_runtime_python-0.1.17 → orca_runtime_python-0.1.19}/orca_runtime_python/persistence.py
RENAMED
|
@@ -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.
|
|
@@ -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,391 @@
|
|
|
1
|
+
"""Tests for production hardening gaps (Gap 1-4, M-1, M-2, M-3)."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import warnings
|
|
5
|
+
from typing import Any
|
|
6
|
+
from orca_runtime_python.parser import parse_orca_md, ParseError
|
|
7
|
+
from orca_runtime_python.machine import OrcaMachine, MachineNotActiveError
|
|
8
|
+
from orca_runtime_python.persistence import PersistenceAdapter, AsyncPersistenceAdapter
|
|
9
|
+
from orca_runtime_python.bus import EventBus
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
SIMPLE_MD = """# machine simple
|
|
13
|
+
## state idle [initial]
|
|
14
|
+
## state done [final]
|
|
15
|
+
## transitions
|
|
16
|
+
| Source | Event | Guard | Target | Action |
|
|
17
|
+
|--------|-------|-------|--------|--------|
|
|
18
|
+
| idle | GO | | done | |
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
VERSIONED_MD = """# machine versioned
|
|
22
|
+
- version: 2.0.0
|
|
23
|
+
## state idle [initial]
|
|
24
|
+
## state done [final]
|
|
25
|
+
## transitions
|
|
26
|
+
| Source | Event | Guard | Target | Action |
|
|
27
|
+
|--------|-------|-------|--------|--------|
|
|
28
|
+
| idle | GO | | done | |
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
# ── Gap 1: snapshot includes machine name and definition_version ──────────────
|
|
33
|
+
|
|
34
|
+
async def _test_snapshot_includes_machine_name():
|
|
35
|
+
bus = EventBus()
|
|
36
|
+
defn = parse_orca_md(SIMPLE_MD)
|
|
37
|
+
machine = OrcaMachine(defn, event_bus=bus)
|
|
38
|
+
await machine.start()
|
|
39
|
+
snap = machine.snapshot()
|
|
40
|
+
assert snap["machine"] == "simple", f"Expected 'simple', got '{snap['machine']}'"
|
|
41
|
+
assert "definition_version" in snap, "Expected definition_version in snapshot"
|
|
42
|
+
assert snap["definition_version"] == "0.1.0"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def test_snapshot_includes_machine_name():
|
|
46
|
+
asyncio.run(_test_snapshot_includes_machine_name())
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
# ── Gap 2: send() before start() raises MachineNotActiveError ────────────────
|
|
50
|
+
|
|
51
|
+
async def _test_send_before_start_raises():
|
|
52
|
+
bus = EventBus()
|
|
53
|
+
defn = parse_orca_md(SIMPLE_MD)
|
|
54
|
+
machine = OrcaMachine(defn, event_bus=bus)
|
|
55
|
+
raised = False
|
|
56
|
+
try:
|
|
57
|
+
await machine.send("GO")
|
|
58
|
+
except MachineNotActiveError:
|
|
59
|
+
raised = True
|
|
60
|
+
assert raised, "Expected MachineNotActiveError when send() called before start()"
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
async def _test_send_after_stop_raises():
|
|
64
|
+
bus = EventBus()
|
|
65
|
+
defn = parse_orca_md(SIMPLE_MD)
|
|
66
|
+
machine = OrcaMachine(defn, event_bus=bus)
|
|
67
|
+
await machine.start()
|
|
68
|
+
await machine.stop()
|
|
69
|
+
raised = False
|
|
70
|
+
try:
|
|
71
|
+
await machine.send("GO")
|
|
72
|
+
except MachineNotActiveError:
|
|
73
|
+
raised = True
|
|
74
|
+
assert raised, "Expected MachineNotActiveError when send() called after stop()"
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
async def _test_send_after_start_does_not_raise():
|
|
78
|
+
bus = EventBus()
|
|
79
|
+
defn = parse_orca_md(SIMPLE_MD)
|
|
80
|
+
machine = OrcaMachine(defn, event_bus=bus)
|
|
81
|
+
await machine.start()
|
|
82
|
+
result = await machine.send("GO")
|
|
83
|
+
assert result.taken is True
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def test_send_before_start_raises():
|
|
87
|
+
asyncio.run(_test_send_before_start_raises())
|
|
88
|
+
|
|
89
|
+
def test_send_after_stop_raises():
|
|
90
|
+
asyncio.run(_test_send_after_stop_raises())
|
|
91
|
+
|
|
92
|
+
def test_send_after_start_does_not_raise():
|
|
93
|
+
asyncio.run(_test_send_after_start_does_not_raise())
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
# ── Gap 3: AsyncPersistenceAdapter + auto-save + load_or_start ───────────────
|
|
97
|
+
|
|
98
|
+
class _InMemorySync:
|
|
99
|
+
"""Synchronous in-memory persistence for testing."""
|
|
100
|
+
def __init__(self):
|
|
101
|
+
self._store: dict[str, Any] = {}
|
|
102
|
+
self.save_calls: int = 0
|
|
103
|
+
|
|
104
|
+
def save(self, run_id: str, snapshot: dict[str, Any]) -> None:
|
|
105
|
+
self._store[run_id] = snapshot
|
|
106
|
+
self.save_calls += 1
|
|
107
|
+
|
|
108
|
+
def load(self, run_id: str) -> dict[str, Any] | None:
|
|
109
|
+
return self._store.get(run_id)
|
|
110
|
+
|
|
111
|
+
def exists(self, run_id: str) -> bool:
|
|
112
|
+
return run_id in self._store
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
class _InMemoryAsync:
|
|
116
|
+
"""Async in-memory persistence for testing."""
|
|
117
|
+
def __init__(self):
|
|
118
|
+
self._store: dict[str, Any] = {}
|
|
119
|
+
self.save_calls: int = 0
|
|
120
|
+
|
|
121
|
+
async def save(self, run_id: str, snapshot: dict[str, Any]) -> None:
|
|
122
|
+
self._store[run_id] = snapshot
|
|
123
|
+
self.save_calls += 1
|
|
124
|
+
|
|
125
|
+
async def load(self, run_id: str) -> dict[str, Any] | None:
|
|
126
|
+
return self._store.get(run_id)
|
|
127
|
+
|
|
128
|
+
async def exists(self, run_id: str) -> bool:
|
|
129
|
+
return run_id in self._store
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
async def _test_auto_save_sync_persistence():
|
|
133
|
+
bus = EventBus()
|
|
134
|
+
defn = parse_orca_md(SIMPLE_MD)
|
|
135
|
+
p = _InMemorySync()
|
|
136
|
+
machine = OrcaMachine(defn, event_bus=bus, persistence=p, run_id="run-1")
|
|
137
|
+
await machine.start()
|
|
138
|
+
assert p.save_calls == 0, "No save before first transition"
|
|
139
|
+
await machine.send("GO")
|
|
140
|
+
assert p.save_calls == 1, "Expected one save after transition"
|
|
141
|
+
snap = p.load("run-1")
|
|
142
|
+
assert snap is not None
|
|
143
|
+
assert snap["machine"] == "simple"
|
|
144
|
+
assert snap["state"] == "done"
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
async def _test_auto_save_async_persistence():
|
|
148
|
+
bus = EventBus()
|
|
149
|
+
defn = parse_orca_md(SIMPLE_MD)
|
|
150
|
+
p = _InMemoryAsync()
|
|
151
|
+
machine = OrcaMachine(defn, event_bus=bus, persistence=p, run_id="run-1")
|
|
152
|
+
await machine.start()
|
|
153
|
+
await machine.send("GO")
|
|
154
|
+
assert p.save_calls == 1, "Expected one save after transition"
|
|
155
|
+
snap = await p.load("run-1")
|
|
156
|
+
assert snap is not None
|
|
157
|
+
assert snap["state"] == "done"
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
async def _test_load_or_start_fresh():
|
|
161
|
+
bus = EventBus()
|
|
162
|
+
defn = parse_orca_md(SIMPLE_MD)
|
|
163
|
+
p = _InMemorySync()
|
|
164
|
+
machine = OrcaMachine(defn, event_bus=bus, persistence=p, run_id="run-fresh")
|
|
165
|
+
await machine.load_or_start()
|
|
166
|
+
assert machine.is_active
|
|
167
|
+
assert machine.state.leaf() == "idle"
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
async def _test_load_or_start_resumes():
|
|
171
|
+
bus = EventBus()
|
|
172
|
+
defn = parse_orca_md(SIMPLE_MD)
|
|
173
|
+
p = _InMemorySync()
|
|
174
|
+
|
|
175
|
+
# First run: advance to done and save
|
|
176
|
+
m1 = OrcaMachine(defn, event_bus=bus, persistence=p, run_id="run-resume")
|
|
177
|
+
await m1.start()
|
|
178
|
+
await m1.send("GO")
|
|
179
|
+
assert p.exists("run-resume")
|
|
180
|
+
|
|
181
|
+
# Second run: load_or_start should resume
|
|
182
|
+
bus2 = EventBus()
|
|
183
|
+
m2 = OrcaMachine(defn, event_bus=bus2, persistence=p, run_id="run-resume")
|
|
184
|
+
await m2.load_or_start()
|
|
185
|
+
assert m2.is_active
|
|
186
|
+
assert m2.state.leaf() == "done"
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
async def _test_load_or_start_no_persistence():
|
|
190
|
+
bus = EventBus()
|
|
191
|
+
defn = parse_orca_md(SIMPLE_MD)
|
|
192
|
+
machine = OrcaMachine(defn, event_bus=bus)
|
|
193
|
+
await machine.load_or_start()
|
|
194
|
+
assert machine.is_active
|
|
195
|
+
assert machine.state.leaf() == "idle"
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def test_auto_save_sync_persistence():
|
|
199
|
+
asyncio.run(_test_auto_save_sync_persistence())
|
|
200
|
+
|
|
201
|
+
def test_auto_save_async_persistence():
|
|
202
|
+
asyncio.run(_test_auto_save_async_persistence())
|
|
203
|
+
|
|
204
|
+
def test_load_or_start_fresh():
|
|
205
|
+
asyncio.run(_test_load_or_start_fresh())
|
|
206
|
+
|
|
207
|
+
def test_load_or_start_resumes():
|
|
208
|
+
asyncio.run(_test_load_or_start_resumes())
|
|
209
|
+
|
|
210
|
+
def test_load_or_start_no_persistence():
|
|
211
|
+
asyncio.run(_test_load_or_start_no_persistence())
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
# ── Gap 4: version field on MachineDef; snapshot includes it; resume warns ───
|
|
215
|
+
|
|
216
|
+
def test_machine_def_default_version():
|
|
217
|
+
defn = parse_orca_md(SIMPLE_MD)
|
|
218
|
+
assert defn.version == "0.1.0"
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def test_machine_def_parsed_version():
|
|
222
|
+
defn = parse_orca_md(VERSIONED_MD)
|
|
223
|
+
assert defn.version == "2.0.0"
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
async def _test_resume_version_mismatch_warns():
|
|
227
|
+
bus = EventBus()
|
|
228
|
+
defn = parse_orca_md(VERSIONED_MD)
|
|
229
|
+
machine = OrcaMachine(defn, event_bus=bus)
|
|
230
|
+
|
|
231
|
+
stale_snap = {
|
|
232
|
+
"machine": "versioned",
|
|
233
|
+
"definition_version": "1.0.0",
|
|
234
|
+
"state": "idle",
|
|
235
|
+
"context": {},
|
|
236
|
+
"children": {},
|
|
237
|
+
"active_invoke": None,
|
|
238
|
+
"timestamp": 0.0,
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
with warnings.catch_warnings(record=True) as caught:
|
|
242
|
+
warnings.simplefilter("always")
|
|
243
|
+
await machine.resume(stale_snap)
|
|
244
|
+
version_warnings = [w for w in caught if issubclass(w.category, UserWarning)]
|
|
245
|
+
assert len(version_warnings) == 1
|
|
246
|
+
assert "1.0.0" in str(version_warnings[0].message)
|
|
247
|
+
assert "2.0.0" in str(version_warnings[0].message)
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
async def _test_resume_matching_version_no_warn():
|
|
251
|
+
bus = EventBus()
|
|
252
|
+
defn = parse_orca_md(VERSIONED_MD)
|
|
253
|
+
machine = OrcaMachine(defn, event_bus=bus)
|
|
254
|
+
|
|
255
|
+
snap = {
|
|
256
|
+
"machine": "versioned",
|
|
257
|
+
"definition_version": "2.0.0",
|
|
258
|
+
"state": "idle",
|
|
259
|
+
"context": {},
|
|
260
|
+
"children": {},
|
|
261
|
+
"active_invoke": None,
|
|
262
|
+
"timestamp": 0.0,
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
with warnings.catch_warnings(record=True) as caught:
|
|
266
|
+
warnings.simplefilter("always")
|
|
267
|
+
await machine.resume(snap)
|
|
268
|
+
version_warnings = [w for w in caught if issubclass(w.category, UserWarning)]
|
|
269
|
+
assert len(version_warnings) == 0, "No warning expected for matching versions"
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def test_resume_version_mismatch_warns():
|
|
273
|
+
asyncio.run(_test_resume_version_mismatch_warns())
|
|
274
|
+
|
|
275
|
+
def test_resume_matching_version_no_warn():
|
|
276
|
+
asyncio.run(_test_resume_matching_version_no_warn())
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
# ── M-1: parser raises ParseError on structurally invalid input ──────────────
|
|
280
|
+
|
|
281
|
+
def test_parse_error_no_initial_state():
|
|
282
|
+
md = """# machine bad
|
|
283
|
+
## state a
|
|
284
|
+
## state b
|
|
285
|
+
## transitions
|
|
286
|
+
| Source | Event | Guard | Target | Action |
|
|
287
|
+
|--------|-------|-------|--------|--------|
|
|
288
|
+
| a | GO | | b | |
|
|
289
|
+
"""
|
|
290
|
+
raised = False
|
|
291
|
+
try:
|
|
292
|
+
parse_orca_md(md)
|
|
293
|
+
except ParseError as e:
|
|
294
|
+
raised = True
|
|
295
|
+
assert "no [initial] state" in str(e)
|
|
296
|
+
assert raised, "Expected ParseError for missing [initial] state"
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def test_parse_error_undefined_transition_target():
|
|
300
|
+
md = """# machine bad
|
|
301
|
+
## state a [initial]
|
|
302
|
+
## transitions
|
|
303
|
+
| Source | Event | Guard | Target | Action |
|
|
304
|
+
|--------|-------|-------|--------|--------|
|
|
305
|
+
| a | GO | | nonexistent | |
|
|
306
|
+
"""
|
|
307
|
+
raised = False
|
|
308
|
+
try:
|
|
309
|
+
parse_orca_md(md)
|
|
310
|
+
except ParseError as e:
|
|
311
|
+
raised = True
|
|
312
|
+
assert "nonexistent" in str(e)
|
|
313
|
+
assert raised, "Expected ParseError for undefined transition target"
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def test_parse_error_undefined_guard():
|
|
317
|
+
md = """# machine bad
|
|
318
|
+
## state a [initial]
|
|
319
|
+
## state b [final]
|
|
320
|
+
## transitions
|
|
321
|
+
| Source | Event | Guard | Target | Action |
|
|
322
|
+
|--------|-------|-------|--------|--------|
|
|
323
|
+
| a | GO | noSuchGuard | b | |
|
|
324
|
+
"""
|
|
325
|
+
raised = False
|
|
326
|
+
try:
|
|
327
|
+
parse_orca_md(md)
|
|
328
|
+
except ParseError as e:
|
|
329
|
+
raised = True
|
|
330
|
+
assert "noSuchGuard" in str(e)
|
|
331
|
+
assert raised, "Expected ParseError for undefined guard"
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def test_parse_valid_machine_no_error():
|
|
335
|
+
defn = parse_orca_md(SIMPLE_MD)
|
|
336
|
+
assert defn.name == "simple"
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
# ── M-2: TransitionResult.to_state_leaf ──────────────────────────────────────
|
|
340
|
+
|
|
341
|
+
async def _test_transition_result_leaf():
|
|
342
|
+
bus = EventBus()
|
|
343
|
+
defn = parse_orca_md(SIMPLE_MD)
|
|
344
|
+
machine = OrcaMachine(defn, event_bus=bus)
|
|
345
|
+
await machine.start()
|
|
346
|
+
result = await machine.send("GO")
|
|
347
|
+
assert result.taken is True
|
|
348
|
+
assert result.to_state_leaf == "done", f"Expected 'done', got '{result.to_state_leaf}'"
|
|
349
|
+
assert result.to_state == "done"
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def test_transition_result_leaf():
|
|
353
|
+
asyncio.run(_test_transition_result_leaf())
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
if __name__ == "__main__":
|
|
357
|
+
tests = [
|
|
358
|
+
("Gap1: snapshot includes machine name", test_snapshot_includes_machine_name),
|
|
359
|
+
("Gap2: send before start raises", test_send_before_start_raises),
|
|
360
|
+
("Gap2: send after stop raises", test_send_after_stop_raises),
|
|
361
|
+
("Gap2: send after start ok", test_send_after_start_does_not_raise),
|
|
362
|
+
("Gap3: auto-save sync", test_auto_save_sync_persistence),
|
|
363
|
+
("Gap3: auto-save async", test_auto_save_async_persistence),
|
|
364
|
+
("Gap3: load_or_start fresh", test_load_or_start_fresh),
|
|
365
|
+
("Gap3: load_or_start resumes", test_load_or_start_resumes),
|
|
366
|
+
("Gap3: load_or_start no persistence", test_load_or_start_no_persistence),
|
|
367
|
+
("Gap4: default version", test_machine_def_default_version),
|
|
368
|
+
("Gap4: parsed version", test_machine_def_parsed_version),
|
|
369
|
+
("Gap4: resume version mismatch warns", test_resume_version_mismatch_warns),
|
|
370
|
+
("Gap4: resume matching version no warn", test_resume_matching_version_no_warn),
|
|
371
|
+
("M-1: no initial state error", test_parse_error_no_initial_state),
|
|
372
|
+
("M-1: undefined target error", test_parse_error_undefined_transition_target),
|
|
373
|
+
("M-1: undefined guard error", test_parse_error_undefined_guard),
|
|
374
|
+
("M-1: valid machine no error", test_parse_valid_machine_no_error),
|
|
375
|
+
("M-2: to_state_leaf", test_transition_result_leaf),
|
|
376
|
+
]
|
|
377
|
+
|
|
378
|
+
passed = 0
|
|
379
|
+
failed = 0
|
|
380
|
+
for name, fn in tests:
|
|
381
|
+
try:
|
|
382
|
+
fn()
|
|
383
|
+
print(f" PASS {name}")
|
|
384
|
+
passed += 1
|
|
385
|
+
except Exception as e:
|
|
386
|
+
print(f" FAIL {name}: {e}")
|
|
387
|
+
failed += 1
|
|
388
|
+
|
|
389
|
+
print(f"\n{passed} passed, {failed} failed, {passed + failed} total")
|
|
390
|
+
if failed > 0:
|
|
391
|
+
exit(1)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{orca_runtime_python-0.1.17 → orca_runtime_python-0.1.19}/orca_runtime_python.egg-info/requires.txt
RENAMED
|
File without changes
|
{orca_runtime_python-0.1.17 → orca_runtime_python-0.1.19}/orca_runtime_python.egg-info/top_level.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|