mycorrhizal 0.1.2__py3-none-any.whl → 0.2.0__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.
- mycorrhizal/_version.py +1 -1
- mycorrhizal/common/__init__.py +15 -3
- mycorrhizal/common/cache.py +114 -0
- mycorrhizal/common/compilation.py +263 -0
- mycorrhizal/common/interface_detection.py +159 -0
- mycorrhizal/common/interfaces.py +3 -50
- mycorrhizal/common/mermaid.py +124 -0
- mycorrhizal/common/wrappers.py +1 -1
- mycorrhizal/hypha/core/builder.py +11 -1
- mycorrhizal/hypha/core/runtime.py +242 -107
- mycorrhizal/mycelium/__init__.py +174 -0
- mycorrhizal/mycelium/core.py +619 -0
- mycorrhizal/mycelium/exceptions.py +30 -0
- mycorrhizal/mycelium/hypha_bridge.py +1143 -0
- mycorrhizal/mycelium/instance.py +440 -0
- mycorrhizal/mycelium/pn_context.py +276 -0
- mycorrhizal/mycelium/runner.py +165 -0
- mycorrhizal/mycelium/spores_integration.py +655 -0
- mycorrhizal/mycelium/tree_builder.py +102 -0
- mycorrhizal/mycelium/tree_spec.py +197 -0
- mycorrhizal/rhizomorph/README.md +82 -33
- mycorrhizal/rhizomorph/core.py +287 -119
- mycorrhizal/septum/TRANSITION_REFERENCE.md +385 -0
- mycorrhizal/{enoki → septum}/core.py +326 -100
- mycorrhizal/{enoki → septum}/testing_utils.py +7 -7
- mycorrhizal/{enoki → septum}/util.py +44 -21
- mycorrhizal/spores/__init__.py +3 -3
- mycorrhizal/spores/core.py +149 -28
- mycorrhizal/spores/dsl/__init__.py +8 -8
- mycorrhizal/spores/dsl/hypha.py +3 -15
- mycorrhizal/spores/dsl/rhizomorph.py +3 -11
- mycorrhizal/spores/dsl/{enoki.py → septum.py} +26 -77
- mycorrhizal/spores/encoder/json.py +21 -12
- mycorrhizal/spores/extraction.py +14 -11
- mycorrhizal/spores/models.py +53 -20
- mycorrhizal-0.2.0.dist-info/METADATA +335 -0
- mycorrhizal-0.2.0.dist-info/RECORD +54 -0
- mycorrhizal-0.1.2.dist-info/METADATA +0 -198
- mycorrhizal-0.1.2.dist-info/RECORD +0 -39
- /mycorrhizal/{enoki → septum}/__init__.py +0 -0
- {mycorrhizal-0.1.2.dist-info → mycorrhizal-0.2.0.dist-info}/WHEEL +0 -0
|
@@ -1,27 +1,27 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
2
|
"""
|
|
3
|
-
|
|
3
|
+
Septum - Asyncio Finite State Machine Framework
|
|
4
4
|
|
|
5
5
|
A decorator-based DSL for defining and executing state machines with support for
|
|
6
6
|
asyncio, timeouts, message passing, and hierarchical state composition.
|
|
7
7
|
|
|
8
8
|
Usage:
|
|
9
|
-
from mycorrhizal.
|
|
9
|
+
from mycorrhizal.septum.core import septum, StateMachine, LabeledTransition
|
|
10
10
|
from enum import Enum, auto
|
|
11
11
|
|
|
12
|
-
@
|
|
12
|
+
@septum.state()
|
|
13
13
|
def IdleState():
|
|
14
14
|
class Events(Enum):
|
|
15
15
|
START = auto()
|
|
16
16
|
QUIT = auto()
|
|
17
17
|
|
|
18
|
-
@
|
|
18
|
+
@septum.on_state
|
|
19
19
|
async def on_state(ctx):
|
|
20
20
|
if ctx.msg == "start":
|
|
21
21
|
return Events.START
|
|
22
22
|
return None
|
|
23
23
|
|
|
24
|
-
@
|
|
24
|
+
@septum.transitions
|
|
25
25
|
def transitions():
|
|
26
26
|
return [
|
|
27
27
|
LabeledTransition(Events.START, ProcessingState),
|
|
@@ -68,7 +68,7 @@ import asyncio
|
|
|
68
68
|
from pathlib import Path
|
|
69
69
|
from asyncio import PriorityQueue
|
|
70
70
|
from dataclasses import dataclass, field
|
|
71
|
-
from enum import Enum, auto, IntEnum
|
|
71
|
+
from enum import Enum, auto, IntEnum # noqa: F401 - auto used in docstring examples
|
|
72
72
|
from typing import (
|
|
73
73
|
Any,
|
|
74
74
|
Callable,
|
|
@@ -79,17 +79,42 @@ from typing import (
|
|
|
79
79
|
Awaitable,
|
|
80
80
|
TypeVar,
|
|
81
81
|
Generic,
|
|
82
|
+
Tuple,
|
|
83
|
+
Type,
|
|
84
|
+
)
|
|
85
|
+
from weakref import WeakKeyDictionary # noqa: F401 - reserved for future use
|
|
86
|
+
|
|
87
|
+
from mycorrhizal.common.wrappers import create_view_from_protocol
|
|
88
|
+
from mycorrhizal.common.compilation import (
|
|
89
|
+
_get_compiled_metadata,
|
|
90
|
+
_clear_compilation_cache,
|
|
82
91
|
)
|
|
83
|
-
from functools import cache
|
|
84
92
|
|
|
85
93
|
|
|
86
94
|
T = TypeVar('T')
|
|
87
95
|
|
|
88
96
|
|
|
89
97
|
# ============================================================================
|
|
90
|
-
# Interface
|
|
98
|
+
# Interface View Caching
|
|
91
99
|
# ============================================================================
|
|
92
100
|
|
|
101
|
+
# Cache for interface views to avoid repeated creation
|
|
102
|
+
# Uses (id(blackboard_instance), interface_type) as cache key
|
|
103
|
+
# This is safe because:
|
|
104
|
+
# 1. id() is unique per object instance (memory address)
|
|
105
|
+
# 2. Different blackboard instances get separate cache entries (correct isolation)
|
|
106
|
+
# 3. Same blackboard instance shared across FSMs gets cached views (correct sharing)
|
|
107
|
+
# 4. Cache is module-level global, cleared between tests via _clear_interface_view_cache()
|
|
108
|
+
_interface_view_cache: Dict[Tuple[int, Type], Any] = {}
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _clear_interface_view_cache() -> None:
|
|
112
|
+
"""Clear the interface view cache. Useful for testing."""
|
|
113
|
+
global _interface_view_cache
|
|
114
|
+
_interface_view_cache.clear()
|
|
115
|
+
# Also clear the compilation cache from common module
|
|
116
|
+
_clear_compilation_cache()
|
|
117
|
+
|
|
93
118
|
|
|
94
119
|
def _create_interface_view_for_context(context: SharedContext, handler: Callable) -> SharedContext:
|
|
95
120
|
"""
|
|
@@ -98,41 +123,57 @@ def _create_interface_view_for_context(context: SharedContext, handler: Callable
|
|
|
98
123
|
This enables type-safe, constrained access to blackboard state based on
|
|
99
124
|
interface definitions created with @blackboard_interface.
|
|
100
125
|
|
|
126
|
+
The function signature can use an interface type:
|
|
127
|
+
async def on_state(ctx: SharedContext[MyInterface]):
|
|
128
|
+
# ctx.common is automatically a constrained view
|
|
129
|
+
return Events.DONE
|
|
130
|
+
|
|
101
131
|
Args:
|
|
102
132
|
context: The SharedContext object
|
|
103
133
|
handler: The state handler function to check for interface type hints
|
|
104
134
|
|
|
105
135
|
Returns:
|
|
106
|
-
|
|
136
|
+
The same context object with context.common potentially updated to a constrained view
|
|
137
|
+
|
|
138
|
+
Raises:
|
|
139
|
+
TypeError: If handler is not callable or type hints are malformed
|
|
140
|
+
AttributeError: If type hints reference undefined types
|
|
141
|
+
|
|
142
|
+
Note:
|
|
143
|
+
Uses id() for cache key which is safe because id() is unique per object instance.
|
|
144
|
+
|
|
145
|
+
Mutates context.common in-place which is safe because:
|
|
146
|
+
1. ConstrainedView wraps the underlying blackboard and forwards mutations to it
|
|
147
|
+
2. All interface views for the same blackboard share the same underlying state
|
|
148
|
+
3. Mutations through interface views PERSIST and are visible to subsequent states (this is intentional!)
|
|
149
|
+
4. Each handler call creates a fresh interface view wrapping self.context.common
|
|
150
|
+
5. Different handlers can have different interfaces, but all mutations go to the same blackboard
|
|
151
|
+
6. Readonly enforcement happens in ConstrainedView.__setattr__, not via context isolation
|
|
107
152
|
"""
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
#
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
constrained_common = create_view_from_protocol(context.common, interface_type)
|
|
153
|
+
# Get compiled metadata (uses EAFP pattern internally)
|
|
154
|
+
# Raises specific exceptions if compilation fails
|
|
155
|
+
metadata = _get_compiled_metadata(handler)
|
|
156
|
+
|
|
157
|
+
# If handler has interface type hint, create constrained view
|
|
158
|
+
if metadata.has_interface and metadata.interface_type:
|
|
159
|
+
# Use id() as cache key for singleton instance caching
|
|
160
|
+
cache_key = (id(context.common), metadata.interface_type)
|
|
161
|
+
|
|
162
|
+
# Check cache
|
|
163
|
+
if cache_key in _interface_view_cache:
|
|
164
|
+
constrained_common = _interface_view_cache[cache_key]
|
|
165
|
+
else:
|
|
166
|
+
# Create view
|
|
167
|
+
constrained_common = create_view_from_protocol(
|
|
168
|
+
context.common,
|
|
169
|
+
metadata.interface_type,
|
|
170
|
+
readonly_fields=metadata.readonly_fields
|
|
171
|
+
)
|
|
172
|
+
# Cache for reuse
|
|
173
|
+
_interface_view_cache[cache_key] = constrained_common
|
|
130
174
|
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
except Exception:
|
|
134
|
-
# If anything goes wrong with type inspection, fall back to original context
|
|
135
|
-
pass
|
|
175
|
+
# Update context.common in-place (safe, see Note above)
|
|
176
|
+
context.common = constrained_common
|
|
136
177
|
|
|
137
178
|
return context
|
|
138
179
|
|
|
@@ -152,7 +193,7 @@ class StateConfiguration:
|
|
|
152
193
|
transition (returning None from on_state is allowed).
|
|
153
194
|
|
|
154
195
|
Example:
|
|
155
|
-
@
|
|
196
|
+
@septum.state(config=StateConfiguration(timeout=5.0, retries=3))
|
|
156
197
|
def MyState():
|
|
157
198
|
# State with timeout and retry handling
|
|
158
199
|
pass
|
|
@@ -180,7 +221,7 @@ class SharedContext(Generic[T]):
|
|
|
180
221
|
Type parameter T represents the type of the 'common' field for type safety.
|
|
181
222
|
|
|
182
223
|
Example:
|
|
183
|
-
@
|
|
224
|
+
@septum.on_state
|
|
184
225
|
async def on_state(ctx: SharedContext):
|
|
185
226
|
# Access shared data
|
|
186
227
|
counter = ctx.common.get("counter", 0)
|
|
@@ -428,6 +469,23 @@ def get_all_states() -> Dict[str, StateSpec]:
|
|
|
428
469
|
return _state_registry.copy()
|
|
429
470
|
|
|
430
471
|
|
|
472
|
+
def _clear_state_registry() -> None:
|
|
473
|
+
"""Clear the global state registry. Useful for testing.
|
|
474
|
+
|
|
475
|
+
This function should be called in test fixtures to ensure tests
|
|
476
|
+
don't interfere with each other through shared state registrations.
|
|
477
|
+
|
|
478
|
+
Example:
|
|
479
|
+
@pytest.fixture(autouse=True)
|
|
480
|
+
def clear_registries():
|
|
481
|
+
_clear_state_registry()
|
|
482
|
+
yield
|
|
483
|
+
_clear_state_registry()
|
|
484
|
+
"""
|
|
485
|
+
global _state_registry
|
|
486
|
+
_state_registry.clear()
|
|
487
|
+
|
|
488
|
+
|
|
431
489
|
# ============================================================================
|
|
432
490
|
# StateRegistry - Validation and Resolution
|
|
433
491
|
# ============================================================================
|
|
@@ -455,6 +513,7 @@ class StateRegistry:
|
|
|
455
513
|
- Validating all states in a state machine
|
|
456
514
|
- Getting transition mappings for states
|
|
457
515
|
- Detecting circular references and invalid transitions
|
|
516
|
+
- Per-instance state registries for better modularity
|
|
458
517
|
"""
|
|
459
518
|
|
|
460
519
|
def __init__(self):
|
|
@@ -503,16 +562,14 @@ class StateRegistry:
|
|
|
503
562
|
if state_name in self._state_cache and not validate_only:
|
|
504
563
|
return self._state_cache[state_name]
|
|
505
564
|
|
|
506
|
-
#
|
|
565
|
+
# Use global registry
|
|
507
566
|
state = get_state(state_name)
|
|
508
567
|
if state:
|
|
509
568
|
if not validate_only:
|
|
510
569
|
self._state_cache[state_name] = state
|
|
511
570
|
return state
|
|
512
571
|
|
|
513
|
-
#
|
|
514
|
-
# (In the old system, it would try dynamic imports, but with
|
|
515
|
-
# decorator-based states, everything should be pre-registered)
|
|
572
|
+
# State not found in registry
|
|
516
573
|
if validate_only:
|
|
517
574
|
self._validation_errors.append(
|
|
518
575
|
f"State '{state_name}' not found in registry"
|
|
@@ -609,8 +666,8 @@ class StateRegistry:
|
|
|
609
666
|
f"State '{state_name}' has a push transition without a label"
|
|
610
667
|
)
|
|
611
668
|
handle_push(transition)
|
|
612
|
-
case LabeledTransition(
|
|
613
|
-
match
|
|
669
|
+
case LabeledTransition():
|
|
670
|
+
match transition.transition:
|
|
614
671
|
case st if isinstance(st, StateSpec):
|
|
615
672
|
handle_state(st)
|
|
616
673
|
case r if isinstance(r, StateRef):
|
|
@@ -670,11 +727,21 @@ class StateRegistry:
|
|
|
670
727
|
discovered_states.add(resolved.name)
|
|
671
728
|
states_to_visit.append(resolved)
|
|
672
729
|
|
|
673
|
-
except Exception
|
|
730
|
+
except Exception:
|
|
674
731
|
self._validation_errors.append(
|
|
675
732
|
f"Error processing transitions for state '{state_name}': {traceback.format_exc()}"
|
|
676
733
|
)
|
|
677
734
|
|
|
735
|
+
# Run PDA-specific validation
|
|
736
|
+
pda_result = self.validate_pda_properties(initial_state, error_state)
|
|
737
|
+
self._validation_errors.extend(pda_result.errors)
|
|
738
|
+
self._validation_warnings.extend(pda_result.warnings)
|
|
739
|
+
|
|
740
|
+
# Run determinism validation
|
|
741
|
+
determ_result = self.validate_determinism(initial_state, error_state)
|
|
742
|
+
self._validation_errors.extend(determ_result.errors)
|
|
743
|
+
self._validation_warnings.extend(determ_result.warnings)
|
|
744
|
+
|
|
678
745
|
return ValidationResult(
|
|
679
746
|
valid=len(self._validation_errors) == 0,
|
|
680
747
|
errors=self._validation_errors.copy(),
|
|
@@ -707,13 +774,23 @@ class StateRegistry:
|
|
|
707
774
|
for transition in raw_transitions:
|
|
708
775
|
match transition:
|
|
709
776
|
case LabeledTransition(label, target):
|
|
777
|
+
# Resolve target if it's a StateRef
|
|
778
|
+
if isinstance(target, StateRef):
|
|
779
|
+
resolved_target = self.resolve_state(target)
|
|
780
|
+
if not resolved_target:
|
|
781
|
+
# Can't resolve - skip this transition
|
|
782
|
+
continue
|
|
783
|
+
resolved = resolved_target
|
|
784
|
+
else:
|
|
785
|
+
resolved = target
|
|
786
|
+
|
|
710
787
|
# Use the label enum itself as the key
|
|
711
|
-
resolved_transitions[label] =
|
|
788
|
+
resolved_transitions[label] = resolved
|
|
712
789
|
# Also allow lookup by label name
|
|
713
|
-
resolved_transitions[label.name] =
|
|
790
|
+
resolved_transitions[label.name] = resolved
|
|
714
791
|
# Also allow lookup by label value
|
|
715
792
|
if hasattr(label, 'value'):
|
|
716
|
-
resolved_transitions[label.value] =
|
|
793
|
+
resolved_transitions[label.value] = resolved
|
|
717
794
|
case s if isinstance(s, StateSpec):
|
|
718
795
|
# Direct state reference
|
|
719
796
|
resolved_transitions[s.name] = s
|
|
@@ -730,6 +807,101 @@ class StateRegistry:
|
|
|
730
807
|
self._transition_cache[state_name] = resolved_transitions
|
|
731
808
|
return resolved_transitions
|
|
732
809
|
|
|
810
|
+
def validate_pda_properties(
|
|
811
|
+
self,
|
|
812
|
+
initial_state: Union[str, StateRef, StateSpec],
|
|
813
|
+
error_state: Optional[Union[str, StateRef, StateSpec]] = None,
|
|
814
|
+
) -> ValidationResult:
|
|
815
|
+
"""Validate PDA-specific properties (stack depth, push/pop balance)."""
|
|
816
|
+
errors = []
|
|
817
|
+
warnings = []
|
|
818
|
+
|
|
819
|
+
# Get all states from global registry
|
|
820
|
+
all_states = list(get_all_states().values())
|
|
821
|
+
|
|
822
|
+
# Build a map of state name to state
|
|
823
|
+
state_map = {state.name: state for state in all_states}
|
|
824
|
+
|
|
825
|
+
for state_name, state in state_map.items():
|
|
826
|
+
transitions = state.get_transitions()
|
|
827
|
+
|
|
828
|
+
# Check for unbounded push (state pushes itself without Pop)
|
|
829
|
+
for t in transitions:
|
|
830
|
+
if isinstance(t, Push):
|
|
831
|
+
for pushed_state in t.push_states:
|
|
832
|
+
# Check if pushed state is the same as current state
|
|
833
|
+
if isinstance(pushed_state, StateSpec) and pushed_state.name == state_name:
|
|
834
|
+
# Check if there's a Pop transition
|
|
835
|
+
has_pop = any(isinstance(tr, (Pop, type(Pop))) for tr in transitions)
|
|
836
|
+
if not has_pop:
|
|
837
|
+
warnings.append(
|
|
838
|
+
f"State '{state_name}' pushes itself without Pop. "
|
|
839
|
+
"This may cause unbounded stack growth."
|
|
840
|
+
)
|
|
841
|
+
|
|
842
|
+
# Check for unreachable pop (pop without matching push)
|
|
843
|
+
has_pop = any(isinstance(t, (Pop, type(Pop))) for t in transitions)
|
|
844
|
+
if has_pop and state_name != initial_state if isinstance(initial_state, str) else state.name != (initial_state.name if isinstance(initial_state, StateSpec) else ""):
|
|
845
|
+
# Check if this state can be reached via a Push transition
|
|
846
|
+
can_be_pushed = self._check_if_state_can_be_pushed(state, state_map)
|
|
847
|
+
if not can_be_pushed:
|
|
848
|
+
warnings.append(
|
|
849
|
+
f"State '{state_name}' can Pop without being pushed. "
|
|
850
|
+
"This may cause PopFromEmptyStack exception."
|
|
851
|
+
)
|
|
852
|
+
|
|
853
|
+
return ValidationResult(
|
|
854
|
+
valid=len(errors) == 0,
|
|
855
|
+
errors=errors,
|
|
856
|
+
warnings=warnings,
|
|
857
|
+
discovered_states=set(state_map.keys()),
|
|
858
|
+
)
|
|
859
|
+
|
|
860
|
+
def _check_if_state_can_be_pushed(self, state: StateSpec, state_map: Dict[str, StateSpec]) -> bool:
|
|
861
|
+
"""Check if a state can be reached via a Push transition from any other state."""
|
|
862
|
+
for other_state_name, other_state in state_map.items():
|
|
863
|
+
if other_state_name == state.name:
|
|
864
|
+
continue
|
|
865
|
+
transitions = other_state.get_transitions()
|
|
866
|
+
for t in transitions:
|
|
867
|
+
if isinstance(t, Push):
|
|
868
|
+
for pushed_state in t.push_states:
|
|
869
|
+
if isinstance(pushed_state, StateSpec) and pushed_state.name == state.name:
|
|
870
|
+
return True
|
|
871
|
+
return False
|
|
872
|
+
|
|
873
|
+
def validate_determinism(
|
|
874
|
+
self,
|
|
875
|
+
initial_state: Union[str, StateRef, StateSpec],
|
|
876
|
+
error_state: Optional[Union[str, StateRef, StateSpec]] = None,
|
|
877
|
+
) -> ValidationResult:
|
|
878
|
+
"""Check that no state has multiple transitions for same event."""
|
|
879
|
+
errors = []
|
|
880
|
+
|
|
881
|
+
# Get all states from global registry
|
|
882
|
+
all_states = list(get_all_states().values())
|
|
883
|
+
|
|
884
|
+
for state in all_states:
|
|
885
|
+
event_transitions = {}
|
|
886
|
+
|
|
887
|
+
transitions = state.get_transitions()
|
|
888
|
+
for transition in transitions:
|
|
889
|
+
if isinstance(transition, LabeledTransition):
|
|
890
|
+
event = transition.label
|
|
891
|
+
if event in event_transitions:
|
|
892
|
+
errors.append(
|
|
893
|
+
f"State '{state.name}' has multiple transitions for event '{event}'. "
|
|
894
|
+
"This creates non-deterministic behavior."
|
|
895
|
+
)
|
|
896
|
+
event_transitions[event] = transition
|
|
897
|
+
|
|
898
|
+
return ValidationResult(
|
|
899
|
+
valid=len(errors) == 0,
|
|
900
|
+
errors=errors,
|
|
901
|
+
warnings=[],
|
|
902
|
+
discovered_states=set(s.name for s in all_states),
|
|
903
|
+
)
|
|
904
|
+
|
|
733
905
|
|
|
734
906
|
# ============================================================================
|
|
735
907
|
# Message Types for StateMachine
|
|
@@ -748,13 +920,13 @@ class PrioritizedMessage:
|
|
|
748
920
|
|
|
749
921
|
|
|
750
922
|
@dataclass
|
|
751
|
-
class
|
|
923
|
+
class SeptumInternalMessage:
|
|
752
924
|
"""Base class for internal FSM messages"""
|
|
753
925
|
pass
|
|
754
926
|
|
|
755
927
|
|
|
756
928
|
@dataclass
|
|
757
|
-
class TimeoutMessage(
|
|
929
|
+
class TimeoutMessage(SeptumInternalMessage):
|
|
758
930
|
"""Internal message for timeout events"""
|
|
759
931
|
state_name: str
|
|
760
932
|
timeout_id: int
|
|
@@ -793,7 +965,7 @@ class StateMachine:
|
|
|
793
965
|
"""Asyncio-native finite state machine for executing StateSpec-based states.
|
|
794
966
|
|
|
795
967
|
The StateMachine manages state execution, transitions, message passing,
|
|
796
|
-
and lifecycle handling. States are defined using the @
|
|
968
|
+
and lifecycle handling. States are defined using the @septum.state decorator
|
|
797
969
|
and should define on_state, on_enter, on_leave, on_timeout, or on_fail handlers.
|
|
798
970
|
|
|
799
971
|
Args:
|
|
@@ -834,6 +1006,8 @@ class StateMachine:
|
|
|
834
1006
|
def __init__(
|
|
835
1007
|
self,
|
|
836
1008
|
initial_state: Union[str, StateRef, StateSpec],
|
|
1009
|
+
max_queue_size: int = 1000,
|
|
1010
|
+
queue_overflow_policy: str = "block",
|
|
837
1011
|
error_state: Optional[Union[str, StateRef, StateSpec]] = None,
|
|
838
1012
|
filter_fn: Optional[Callable] = None,
|
|
839
1013
|
trap_fn: Optional[Callable] = None,
|
|
@@ -841,8 +1015,13 @@ class StateMachine:
|
|
|
841
1015
|
common_data: Optional[Any] = None,
|
|
842
1016
|
):
|
|
843
1017
|
|
|
1018
|
+
# Use global state registry
|
|
844
1019
|
self.registry = StateRegistry()
|
|
845
1020
|
|
|
1021
|
+
# Store queue configuration
|
|
1022
|
+
self.max_queue_size = max_queue_size
|
|
1023
|
+
self.queue_overflow_policy = queue_overflow_policy
|
|
1024
|
+
|
|
846
1025
|
# The filter and trap functions
|
|
847
1026
|
self._filter_fn = filter_fn or (lambda x, y: None)
|
|
848
1027
|
self._trap_fn = trap_fn or (lambda x: None)
|
|
@@ -873,7 +1052,7 @@ class StateMachine:
|
|
|
873
1052
|
)
|
|
874
1053
|
|
|
875
1054
|
# Asyncio-based infrastructure
|
|
876
|
-
self._message_queue = PriorityQueue()
|
|
1055
|
+
self._message_queue = PriorityQueue(maxsize=max_queue_size)
|
|
877
1056
|
self._timeout_task = None
|
|
878
1057
|
self._timeout_counter = 0
|
|
879
1058
|
self._current_timeout_id = None
|
|
@@ -905,36 +1084,78 @@ class StateMachine:
|
|
|
905
1084
|
if message and self._filter_fn(self.context, message):
|
|
906
1085
|
return
|
|
907
1086
|
self._send_message_internal(message)
|
|
1087
|
+
except asyncio.QueueFull:
|
|
1088
|
+
# Handle overflow based on policy
|
|
1089
|
+
if self.queue_overflow_policy == "block":
|
|
1090
|
+
# Wait for space (synchronous, so raise)
|
|
1091
|
+
raise RuntimeError(
|
|
1092
|
+
"Queue full, would block. Use async send_message_async or check queue depth."
|
|
1093
|
+
)
|
|
1094
|
+
elif self.queue_overflow_policy == "drop_newest":
|
|
1095
|
+
# Drop this message (silently)
|
|
1096
|
+
pass
|
|
1097
|
+
elif self.queue_overflow_policy == "fail":
|
|
1098
|
+
raise RuntimeError(
|
|
1099
|
+
f"Queue full (max={self.max_queue_size}), message dropped"
|
|
1100
|
+
)
|
|
1101
|
+
# Note: "drop_oldest" is handled in _send_message_internal
|
|
908
1102
|
except Exception as e:
|
|
909
1103
|
self._send_message_internal(e)
|
|
910
1104
|
|
|
911
|
-
def
|
|
912
|
-
"""
|
|
1105
|
+
def _get_message_priority(self, message: Any) -> int:
|
|
1106
|
+
"""Get message priority for queuing."""
|
|
1107
|
+
if isinstance(message, SeptumInternalMessage):
|
|
1108
|
+
return self.MessagePriorities.INTERNAL_MESSAGE
|
|
1109
|
+
elif isinstance(message, Exception):
|
|
1110
|
+
return self.MessagePriorities.ERROR
|
|
1111
|
+
else:
|
|
1112
|
+
return self.MessagePriorities.MESSAGE
|
|
1113
|
+
|
|
1114
|
+
def _send_message_internal(self, message: Any) -> None:
|
|
1115
|
+
"""Internal message sending with priority handling."""
|
|
1116
|
+
priority = self._get_message_priority(message)
|
|
913
1117
|
|
|
914
1118
|
match message:
|
|
915
|
-
case _ if isinstance(message,
|
|
1119
|
+
case _ if isinstance(message, SeptumInternalMessage):
|
|
916
1120
|
try:
|
|
917
1121
|
self._message_queue.put_nowait(
|
|
918
|
-
PrioritizedMessage(
|
|
919
|
-
self.MessagePriorities.INTERNAL_MESSAGE, message
|
|
920
|
-
)
|
|
1122
|
+
PrioritizedMessage(priority, message)
|
|
921
1123
|
)
|
|
922
|
-
except
|
|
923
|
-
|
|
1124
|
+
except asyncio.QueueFull:
|
|
1125
|
+
if self.queue_overflow_policy == "drop_oldest":
|
|
1126
|
+
# Drop oldest message
|
|
1127
|
+
try:
|
|
1128
|
+
self._message_queue.get_nowait()
|
|
1129
|
+
self._message_queue.put_nowait(PrioritizedMessage(priority, message))
|
|
1130
|
+
except asyncio.QueueEmpty:
|
|
1131
|
+
pass # Queue was actually empty, race condition
|
|
1132
|
+
# Other policies handled by send_message
|
|
924
1133
|
case _ if isinstance(message, Exception):
|
|
925
1134
|
try:
|
|
926
1135
|
self._message_queue.put_nowait(
|
|
927
|
-
PrioritizedMessage(
|
|
1136
|
+
PrioritizedMessage(priority, message)
|
|
928
1137
|
)
|
|
929
|
-
except
|
|
930
|
-
|
|
1138
|
+
except asyncio.QueueFull:
|
|
1139
|
+
if self.queue_overflow_policy == "drop_oldest":
|
|
1140
|
+
# Drop oldest message
|
|
1141
|
+
try:
|
|
1142
|
+
self._message_queue.get_nowait()
|
|
1143
|
+
self._message_queue.put_nowait(PrioritizedMessage(priority, message))
|
|
1144
|
+
except asyncio.QueueEmpty:
|
|
1145
|
+
pass # Queue was actually empty, race condition
|
|
931
1146
|
case _:
|
|
932
1147
|
try:
|
|
933
1148
|
self._message_queue.put_nowait(
|
|
934
|
-
PrioritizedMessage(
|
|
1149
|
+
PrioritizedMessage(priority, message)
|
|
935
1150
|
)
|
|
936
|
-
except
|
|
937
|
-
|
|
1151
|
+
except asyncio.QueueFull:
|
|
1152
|
+
if self.queue_overflow_policy == "drop_oldest":
|
|
1153
|
+
# Drop oldest message
|
|
1154
|
+
try:
|
|
1155
|
+
self._message_queue.get_nowait()
|
|
1156
|
+
self._message_queue.put_nowait(PrioritizedMessage(priority, message))
|
|
1157
|
+
except asyncio.QueueEmpty:
|
|
1158
|
+
pass # Queue was actually empty, race condition
|
|
938
1159
|
|
|
939
1160
|
async def reset(self):
|
|
940
1161
|
"""Reset the state machine to initial state"""
|
|
@@ -957,6 +1178,11 @@ class StateMachine:
|
|
|
957
1178
|
"""Log a message"""
|
|
958
1179
|
print(f"[FSM] {message}")
|
|
959
1180
|
|
|
1181
|
+
@property
|
|
1182
|
+
def queue_depth(self) -> int:
|
|
1183
|
+
"""Get current message queue depth."""
|
|
1184
|
+
return self._message_queue.qsize()
|
|
1185
|
+
|
|
960
1186
|
async def tick(self, timeout: Optional[Union[float, int]] = 0):
|
|
961
1187
|
"""Process one state machine tick"""
|
|
962
1188
|
|
|
@@ -969,7 +1195,7 @@ class StateMachine:
|
|
|
969
1195
|
# Validate timeout parameter
|
|
970
1196
|
match timeout:
|
|
971
1197
|
case x if x and not isinstance(x, (int, float)):
|
|
972
|
-
raise ValueError(
|
|
1198
|
+
raise ValueError("Tick timeout must be None, or an int/float >= 0")
|
|
973
1199
|
case 0:
|
|
974
1200
|
block = False
|
|
975
1201
|
case None:
|
|
@@ -1192,7 +1418,6 @@ class StateMachine:
|
|
|
1192
1418
|
|
|
1193
1419
|
def _send_timeout_message(self, state_name: str, timeout_id: int, duration: float):
|
|
1194
1420
|
"""Internal method to send timeout messages"""
|
|
1195
|
-
|
|
1196
1421
|
timeout_msg = TimeoutMessage(state_name, timeout_id, duration)
|
|
1197
1422
|
self._send_message_internal(timeout_msg)
|
|
1198
1423
|
|
|
@@ -1294,7 +1519,7 @@ class StateMachine:
|
|
|
1294
1519
|
# Decorators
|
|
1295
1520
|
# ============================================================================
|
|
1296
1521
|
|
|
1297
|
-
class
|
|
1522
|
+
class _SeptumDecoratorAPI:
|
|
1298
1523
|
"""Decorator API for defining states"""
|
|
1299
1524
|
|
|
1300
1525
|
def __init__(self):
|
|
@@ -1354,19 +1579,19 @@ class _EnokiDecoratorAPI:
|
|
|
1354
1579
|
Events = None
|
|
1355
1580
|
|
|
1356
1581
|
for item_name, item_fn in tracked_items:
|
|
1357
|
-
if hasattr(item_fn, "
|
|
1582
|
+
if hasattr(item_fn, "_septum_on_state"):
|
|
1358
1583
|
on_state = item_fn
|
|
1359
|
-
elif hasattr(item_fn, "
|
|
1584
|
+
elif hasattr(item_fn, "_septum_on_enter"):
|
|
1360
1585
|
on_enter = item_fn
|
|
1361
|
-
elif hasattr(item_fn, "
|
|
1586
|
+
elif hasattr(item_fn, "_septum_on_leave"):
|
|
1362
1587
|
on_leave = item_fn
|
|
1363
|
-
elif hasattr(item_fn, "
|
|
1588
|
+
elif hasattr(item_fn, "_septum_on_timeout"):
|
|
1364
1589
|
on_timeout = item_fn
|
|
1365
|
-
elif hasattr(item_fn, "
|
|
1590
|
+
elif hasattr(item_fn, "_septum_on_fail"):
|
|
1366
1591
|
on_fail = item_fn
|
|
1367
|
-
elif hasattr(item_fn, "
|
|
1592
|
+
elif hasattr(item_fn, "_septum_transitions"):
|
|
1368
1593
|
transitions = item_fn
|
|
1369
|
-
elif hasattr(item_fn, "
|
|
1594
|
+
elif hasattr(item_fn, "_septum_events"):
|
|
1370
1595
|
Events = item_fn
|
|
1371
1596
|
|
|
1372
1597
|
# Validate required methods
|
|
@@ -1398,42 +1623,42 @@ class _EnokiDecoratorAPI:
|
|
|
1398
1623
|
|
|
1399
1624
|
def on_state(self, func: Callable) -> Callable:
|
|
1400
1625
|
"""Decorator for the main state logic method"""
|
|
1401
|
-
func.
|
|
1626
|
+
func._septum_on_state = func
|
|
1402
1627
|
if self._tracking_stack:
|
|
1403
1628
|
self._tracking_stack[-1].append((func.__name__, func))
|
|
1404
1629
|
return func
|
|
1405
1630
|
|
|
1406
1631
|
def on_enter(self, func: Callable) -> Callable:
|
|
1407
1632
|
"""Decorator for on_enter lifecycle method"""
|
|
1408
|
-
func.
|
|
1633
|
+
func._septum_on_enter = func
|
|
1409
1634
|
if self._tracking_stack:
|
|
1410
1635
|
self._tracking_stack[-1].append((func.__name__, func))
|
|
1411
1636
|
return func
|
|
1412
1637
|
|
|
1413
1638
|
def on_leave(self, func: Callable) -> Callable:
|
|
1414
1639
|
"""Decorator for on_leave lifecycle method"""
|
|
1415
|
-
func.
|
|
1640
|
+
func._septum_on_leave = func
|
|
1416
1641
|
if self._tracking_stack:
|
|
1417
1642
|
self._tracking_stack[-1].append((func.__name__, func))
|
|
1418
1643
|
return func
|
|
1419
1644
|
|
|
1420
1645
|
def on_timeout(self, func: Callable) -> Callable:
|
|
1421
1646
|
"""Decorator for on_timeout lifecycle method"""
|
|
1422
|
-
func.
|
|
1647
|
+
func._septum_on_timeout = func
|
|
1423
1648
|
if self._tracking_stack:
|
|
1424
1649
|
self._tracking_stack[-1].append((func.__name__, func))
|
|
1425
1650
|
return func
|
|
1426
1651
|
|
|
1427
1652
|
def on_fail(self, func: Callable) -> Callable:
|
|
1428
1653
|
"""Decorator for on_fail lifecycle method"""
|
|
1429
|
-
func.
|
|
1654
|
+
func._septum_on_fail = func
|
|
1430
1655
|
if self._tracking_stack:
|
|
1431
1656
|
self._tracking_stack[-1].append((func.__name__, func))
|
|
1432
1657
|
return func
|
|
1433
1658
|
|
|
1434
1659
|
def transitions(self, func: Callable) -> Callable:
|
|
1435
1660
|
"""Decorator for declaring transitions"""
|
|
1436
|
-
func.
|
|
1661
|
+
func._septum_transitions = func
|
|
1437
1662
|
if self._tracking_stack:
|
|
1438
1663
|
self._tracking_stack[-1].append((func.__name__, func))
|
|
1439
1664
|
return func
|
|
@@ -1456,40 +1681,40 @@ class _EnokiDecoratorAPI:
|
|
|
1456
1681
|
- You don't need external access to the Events enum
|
|
1457
1682
|
|
|
1458
1683
|
Example:
|
|
1459
|
-
@
|
|
1684
|
+
@septum.state()
|
|
1460
1685
|
def MyState():
|
|
1461
|
-
@
|
|
1686
|
+
@septum.events # Optional - makes MyState.Events accessible
|
|
1462
1687
|
class Events(Enum):
|
|
1463
1688
|
GO = auto()
|
|
1464
1689
|
|
|
1465
|
-
@
|
|
1690
|
+
@septum.on_state
|
|
1466
1691
|
async def on_state(ctx):
|
|
1467
|
-
return Events.GO # Works via closure even without @
|
|
1692
|
+
return Events.GO # Works via closure even without @septum.events
|
|
1468
1693
|
"""
|
|
1469
|
-
events_class.
|
|
1694
|
+
events_class._septum_events = events_class
|
|
1470
1695
|
if self._tracking_stack:
|
|
1471
1696
|
self._tracking_stack[-1].append(("Events", events_class))
|
|
1472
1697
|
return events_class
|
|
1473
1698
|
|
|
1474
1699
|
def root(self, func: Callable) -> Callable:
|
|
1475
1700
|
"""Decorator to mark a state as the initial/root state"""
|
|
1476
|
-
func.
|
|
1701
|
+
func._septum_is_root = True
|
|
1477
1702
|
return func
|
|
1478
1703
|
|
|
1479
1704
|
|
|
1480
1705
|
# Create the decorator API instance
|
|
1481
|
-
|
|
1706
|
+
septum = _SeptumDecoratorAPI()
|
|
1482
1707
|
|
|
1483
1708
|
# Export decorators for convenience
|
|
1484
|
-
state =
|
|
1485
|
-
on_state =
|
|
1486
|
-
on_enter =
|
|
1487
|
-
on_leave =
|
|
1488
|
-
on_timeout =
|
|
1489
|
-
on_fail =
|
|
1490
|
-
transitions =
|
|
1491
|
-
events =
|
|
1492
|
-
root =
|
|
1709
|
+
state = septum.state
|
|
1710
|
+
on_state = septum.on_state
|
|
1711
|
+
on_enter = septum.on_enter
|
|
1712
|
+
on_leave = septum.on_leave
|
|
1713
|
+
on_timeout = septum.on_timeout
|
|
1714
|
+
on_fail = septum.on_fail
|
|
1715
|
+
transitions = septum.transitions
|
|
1716
|
+
events = septum.events
|
|
1717
|
+
root = septum.root
|
|
1493
1718
|
|
|
1494
1719
|
|
|
1495
1720
|
# ============================================================================
|
|
@@ -1498,7 +1723,7 @@ root = enoki.root
|
|
|
1498
1723
|
|
|
1499
1724
|
__all__ = [
|
|
1500
1725
|
# Decorators
|
|
1501
|
-
"
|
|
1726
|
+
"septum",
|
|
1502
1727
|
"state",
|
|
1503
1728
|
"on_state",
|
|
1504
1729
|
"on_enter",
|
|
@@ -1529,6 +1754,7 @@ __all__ = [
|
|
|
1529
1754
|
"register_state",
|
|
1530
1755
|
"get_state",
|
|
1531
1756
|
"get_all_states",
|
|
1757
|
+
"_clear_state_registry",
|
|
1532
1758
|
"StateRegistry",
|
|
1533
1759
|
"ValidationError",
|
|
1534
1760
|
"ValidationResult",
|
|
@@ -1540,6 +1766,6 @@ __all__ = [
|
|
|
1540
1766
|
"NoStateToTick",
|
|
1541
1767
|
# Messages
|
|
1542
1768
|
"PrioritizedMessage",
|
|
1543
|
-
"
|
|
1769
|
+
"SeptumInternalMessage",
|
|
1544
1770
|
"TimeoutMessage",
|
|
1545
1771
|
]
|