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.
Files changed (41) hide show
  1. mycorrhizal/_version.py +1 -1
  2. mycorrhizal/common/__init__.py +15 -3
  3. mycorrhizal/common/cache.py +114 -0
  4. mycorrhizal/common/compilation.py +263 -0
  5. mycorrhizal/common/interface_detection.py +159 -0
  6. mycorrhizal/common/interfaces.py +3 -50
  7. mycorrhizal/common/mermaid.py +124 -0
  8. mycorrhizal/common/wrappers.py +1 -1
  9. mycorrhizal/hypha/core/builder.py +11 -1
  10. mycorrhizal/hypha/core/runtime.py +242 -107
  11. mycorrhizal/mycelium/__init__.py +174 -0
  12. mycorrhizal/mycelium/core.py +619 -0
  13. mycorrhizal/mycelium/exceptions.py +30 -0
  14. mycorrhizal/mycelium/hypha_bridge.py +1143 -0
  15. mycorrhizal/mycelium/instance.py +440 -0
  16. mycorrhizal/mycelium/pn_context.py +276 -0
  17. mycorrhizal/mycelium/runner.py +165 -0
  18. mycorrhizal/mycelium/spores_integration.py +655 -0
  19. mycorrhizal/mycelium/tree_builder.py +102 -0
  20. mycorrhizal/mycelium/tree_spec.py +197 -0
  21. mycorrhizal/rhizomorph/README.md +82 -33
  22. mycorrhizal/rhizomorph/core.py +287 -119
  23. mycorrhizal/septum/TRANSITION_REFERENCE.md +385 -0
  24. mycorrhizal/{enoki → septum}/core.py +326 -100
  25. mycorrhizal/{enoki → septum}/testing_utils.py +7 -7
  26. mycorrhizal/{enoki → septum}/util.py +44 -21
  27. mycorrhizal/spores/__init__.py +3 -3
  28. mycorrhizal/spores/core.py +149 -28
  29. mycorrhizal/spores/dsl/__init__.py +8 -8
  30. mycorrhizal/spores/dsl/hypha.py +3 -15
  31. mycorrhizal/spores/dsl/rhizomorph.py +3 -11
  32. mycorrhizal/spores/dsl/{enoki.py → septum.py} +26 -77
  33. mycorrhizal/spores/encoder/json.py +21 -12
  34. mycorrhizal/spores/extraction.py +14 -11
  35. mycorrhizal/spores/models.py +53 -20
  36. mycorrhizal-0.2.0.dist-info/METADATA +335 -0
  37. mycorrhizal-0.2.0.dist-info/RECORD +54 -0
  38. mycorrhizal-0.1.2.dist-info/METADATA +0 -198
  39. mycorrhizal-0.1.2.dist-info/RECORD +0 -39
  40. /mycorrhizal/{enoki → septum}/__init__.py +0 -0
  41. {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
- Enoki - Asyncio Finite State Machine Framework
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.enoki.core import enoki, StateMachine, LabeledTransition
9
+ from mycorrhizal.septum.core import septum, StateMachine, LabeledTransition
10
10
  from enum import Enum, auto
11
11
 
12
- @enoki.state()
12
+ @septum.state()
13
13
  def IdleState():
14
14
  class Events(Enum):
15
15
  START = auto()
16
16
  QUIT = auto()
17
17
 
18
- @enoki.on_state
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
- @enoki.transitions
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 Integration Helper
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
- Either the original context or a new context with constrained common field
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
- from typing import get_type_hints
109
-
110
- try:
111
- sig = inspect.signature(handler)
112
- params = list(sig.parameters.values())
113
-
114
- # Check first parameter (should be SharedContext)
115
- if params and params[0].name == 'ctx':
116
- ctx_type = get_type_hints(handler).get('ctx')
117
-
118
- # If type hint exists and is a generic SharedContext with interface
119
- if ctx_type and hasattr(ctx_type, '__args__'):
120
- # Extract the type argument from SharedContext[InterfaceType]
121
- interface_type = ctx_type.__args__[0]
122
-
123
- # If it has interface metadata, create constrained view
124
- if hasattr(interface_type, '_readonly_fields'):
125
- from mycorrhizal.common.wrappers import create_view_from_protocol
126
- from dataclasses import replace
127
-
128
- # Create constrained view of common
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
- # Return new context with constrained common
132
- return replace(context, common=constrained_common)
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
- @enoki.state(config=StateConfiguration(timeout=5.0, retries=3))
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
- @enoki.on_state
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
- # Try to get from global registry
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
- # If not in global registry, we can't resolve it
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(label, s):
613
- match s:
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 as e:
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] = target
788
+ resolved_transitions[label] = resolved
712
789
  # Also allow lookup by label name
713
- resolved_transitions[label.name] = target
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] = target
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 EnokiInternalMessage:
923
+ class SeptumInternalMessage:
752
924
  """Base class for internal FSM messages"""
753
925
  pass
754
926
 
755
927
 
756
928
  @dataclass
757
- class TimeoutMessage(EnokiInternalMessage):
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 @enoki.state decorator
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 _send_message_internal(self, message: Any):
912
- """Internal message sending"""
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, EnokiInternalMessage):
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 Exception:
923
- pass # Queue full
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(self.MessagePriorities.ERROR, message)
1136
+ PrioritizedMessage(priority, message)
928
1137
  )
929
- except Exception:
930
- pass
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(self.MessagePriorities.MESSAGE, message)
1149
+ PrioritizedMessage(priority, message)
935
1150
  )
936
- except Exception:
937
- pass
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(f"Tick timeout must be None, or an int/float >= 0")
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 _EnokiDecoratorAPI:
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, "_enoki_on_state"):
1582
+ if hasattr(item_fn, "_septum_on_state"):
1358
1583
  on_state = item_fn
1359
- elif hasattr(item_fn, "_enoki_on_enter"):
1584
+ elif hasattr(item_fn, "_septum_on_enter"):
1360
1585
  on_enter = item_fn
1361
- elif hasattr(item_fn, "_enoki_on_leave"):
1586
+ elif hasattr(item_fn, "_septum_on_leave"):
1362
1587
  on_leave = item_fn
1363
- elif hasattr(item_fn, "_enoki_on_timeout"):
1588
+ elif hasattr(item_fn, "_septum_on_timeout"):
1364
1589
  on_timeout = item_fn
1365
- elif hasattr(item_fn, "_enoki_on_fail"):
1590
+ elif hasattr(item_fn, "_septum_on_fail"):
1366
1591
  on_fail = item_fn
1367
- elif hasattr(item_fn, "_enoki_transitions"):
1592
+ elif hasattr(item_fn, "_septum_transitions"):
1368
1593
  transitions = item_fn
1369
- elif hasattr(item_fn, "_enoki_events"):
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._enoki_on_state = 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._enoki_on_enter = 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._enoki_on_leave = 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._enoki_on_timeout = 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._enoki_on_fail = 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._enoki_transitions = 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
- @enoki.state()
1684
+ @septum.state()
1460
1685
  def MyState():
1461
- @enoki.events # Optional - makes MyState.Events accessible
1686
+ @septum.events # Optional - makes MyState.Events accessible
1462
1687
  class Events(Enum):
1463
1688
  GO = auto()
1464
1689
 
1465
- @enoki.on_state
1690
+ @septum.on_state
1466
1691
  async def on_state(ctx):
1467
- return Events.GO # Works via closure even without @enoki.events
1692
+ return Events.GO # Works via closure even without @septum.events
1468
1693
  """
1469
- events_class._enoki_events = 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._enoki_is_root = True
1701
+ func._septum_is_root = True
1477
1702
  return func
1478
1703
 
1479
1704
 
1480
1705
  # Create the decorator API instance
1481
- enoki = _EnokiDecoratorAPI()
1706
+ septum = _SeptumDecoratorAPI()
1482
1707
 
1483
1708
  # Export decorators for convenience
1484
- state = enoki.state
1485
- on_state = enoki.on_state
1486
- on_enter = enoki.on_enter
1487
- on_leave = enoki.on_leave
1488
- on_timeout = enoki.on_timeout
1489
- on_fail = enoki.on_fail
1490
- transitions = enoki.transitions
1491
- events = enoki.events
1492
- root = enoki.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
- "enoki",
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
- "EnokiInternalMessage",
1769
+ "SeptumInternalMessage",
1544
1770
  "TimeoutMessage",
1545
1771
  ]