Coverage for src/dataknobs_fsm/api/advanced.py: 22%
544 statements
« prev ^ index » next coverage.py v7.10.6, created at 2025-09-20 16:46 -0600
« prev ^ index » next coverage.py v7.10.6, created at 2025-09-20 16:46 -0600
1"""Advanced API for full control over FSM execution.
3This module provides advanced interfaces for users who need fine-grained
4control over FSM execution, resource management, and monitoring.
5"""
7import time
8from collections.abc import Callable
9from contextlib import asynccontextmanager
10from dataclasses import dataclass, field
11from enum import Enum
12from pathlib import Path
13from typing import Any
15from dataknobs_data import Record
17from ..core.context_factory import ContextFactory
18from ..core.data_modes import DataHandler, DataHandlingMode
19from ..core.fsm import FSM
20from ..core.modes import ProcessingMode
21from ..core.state import StateInstance
22from ..core.transactions import TransactionManager, TransactionStrategy
23from ..execution.async_engine import AsyncExecutionEngine
24from ..execution.context import ExecutionContext
25from ..execution.engine import ExecutionEngine, TraversalStrategy
26from ..execution.history import ExecutionHistory
27from ..resources.base import IResourceProvider
28from ..resources.manager import ResourceManager
29from ..storage.base import IHistoryStorage
32class ExecutionMode(Enum):
33 """Advanced execution modes."""
34 STEP_BY_STEP = "step" # Execute one transition at a time
35 BREAKPOINT = "breakpoint" # Stop at specific states
36 TRACE = "trace" # Full execution tracing
37 PROFILE = "profile" # Performance profiling
38 DEBUG = "debug" # Debug mode with detailed logging
41@dataclass
42class ExecutionHook:
43 """Hook for monitoring execution events."""
44 on_state_enter: Callable | None = None
45 on_state_exit: Callable | None = None
46 on_arc_execute: Callable | None = None
47 on_error: Callable | None = None
48 on_resource_acquire: Callable | None = None
49 on_resource_release: Callable | None = None
50 on_transaction_begin: Callable | None = None
51 on_transaction_commit: Callable | None = None
52 on_transaction_rollback: Callable | None = None
55@dataclass
56class StepResult:
57 """Result from a single step execution."""
58 from_state: str
59 to_state: str
60 transition: str
61 data_before: dict[str, Any] = field(default_factory=dict)
62 data_after: dict[str, Any] = field(default_factory=dict)
63 duration: float = 0.0
64 success: bool = True
65 error: str | None = None
66 at_breakpoint: bool = False
67 is_complete: bool = False
70class AdvancedFSM:
71 """Advanced FSM interface with full control capabilities."""
73 def __init__(
74 self,
75 fsm: FSM,
76 execution_mode: ExecutionMode = ExecutionMode.STEP_BY_STEP,
77 custom_functions: dict[str, Callable] | None = None
78 ):
79 """Initialize AdvancedFSM.
81 Args:
82 fsm: Core FSM instance
83 execution_mode: Execution mode for advanced control
84 custom_functions: Optional custom functions to register
85 """
86 self.fsm = fsm
87 self.execution_mode = execution_mode
88 self._engine = ExecutionEngine(fsm)
89 self._async_engine = AsyncExecutionEngine(fsm)
90 self._resource_manager = ResourceManager()
91 self._transaction_manager = None
92 self._history = None
93 self._storage = None
94 self._hooks = ExecutionHook()
95 self._breakpoints = set()
96 self._trace_buffer = []
97 self._profile_data = {}
98 self._custom_functions = custom_functions or {}
100 def set_execution_strategy(self, strategy: TraversalStrategy) -> None:
101 """Set custom execution strategy.
103 Args:
104 strategy: Execution strategy to use
105 """
106 self._engine.strategy = strategy
108 def set_data_handler(self, handler: DataHandler) -> None:
109 """Set custom data handler.
111 Args:
112 handler: Data handler implementation
113 """
114 self._engine.data_handler = handler
116 def configure_transactions(
117 self,
118 strategy: TransactionStrategy,
119 **config
120 ) -> None:
121 """Configure transaction management.
123 Args:
124 strategy: Transaction strategy to use
125 **config: Strategy-specific configuration
126 """
127 self._transaction_manager = TransactionManager.create(strategy, **config)
129 def register_resource(
130 self,
131 name: str,
132 resource: IResourceProvider | dict[str, Any]
133 ) -> None:
134 """Register a custom resource.
136 Args:
137 name: Resource name
138 resource: Resource instance or configuration
139 """
140 if isinstance(resource, dict):
141 # Use ResourceManager factory method
142 self._resource_manager.register_from_dict(name, resource)
143 else:
144 # Assume it's already a provider
145 self._resource_manager.register_provider(name, resource)
147 def set_hooks(self, hooks: ExecutionHook) -> None:
148 """Set execution hooks for monitoring.
150 Args:
151 hooks: Execution hooks configuration
152 """
153 self._hooks = hooks
155 def add_breakpoint(self, state_name: str) -> None:
156 """Add a breakpoint at a specific state.
158 Args:
159 state_name: Name of state to break at
160 """
161 self._breakpoints.add(state_name)
163 def remove_breakpoint(self, state_name: str) -> None:
164 """Remove a breakpoint.
166 Args:
167 state_name: Name of state to remove breakpoint from
168 """
169 self._breakpoints.discard(state_name)
171 def clear_breakpoints(self) -> None:
172 """Clear all breakpoints."""
173 self._breakpoints.clear()
175 @property
176 def breakpoints(self) -> set:
177 """Get the current breakpoints."""
178 return self._breakpoints.copy()
180 @property
181 def hooks(self) -> ExecutionHook:
182 """Get the current execution hooks."""
183 return self._hooks
185 @property
186 def history_enabled(self) -> bool:
187 """Check if history tracking is enabled."""
188 return self._history is not None
190 @property
191 def max_history_depth(self) -> int:
192 """Get the maximum history depth."""
193 return self._history.max_depth if self._history else 0
195 @property
196 def execution_history(self) -> list:
197 """Get the execution history steps."""
198 return self._history.steps if self._history else []
200 def enable_history(
201 self,
202 storage: IHistoryStorage | None = None,
203 max_depth: int = 100
204 ) -> None:
205 """Enable execution history tracking.
207 Args:
208 storage: Optional storage backend for history
209 max_depth: Maximum history depth to track
210 """
211 import uuid
213 from dataknobs_fsm.core.data_modes import DataHandlingMode
215 # Get FSM name from the FSM object
216 fsm_name = getattr(self.fsm, 'name', 'unnamed_fsm')
218 # Generate a unique execution ID
219 execution_id = str(uuid.uuid4())
221 self._history = ExecutionHistory(
222 fsm_name=fsm_name,
223 execution_id=execution_id,
224 data_mode=DataHandlingMode.COPY, # Default data mode
225 max_depth=max_depth
226 )
227 self._storage = storage
229 def disable_history(self) -> None:
230 """Disable history tracking."""
231 self._history = None
232 self._storage = None
234 def create_context(
235 self,
236 data: dict[str, Any] | Record,
237 data_mode: DataHandlingMode = DataHandlingMode.COPY,
238 initial_state: str | None = None
239 ) -> ExecutionContext:
240 """Create an execution context for manual control (synchronous).
242 Args:
243 data: Initial data
244 data_mode: Data handling mode
245 initial_state: Starting state name
247 Returns:
248 ExecutionContext for manual execution
249 """
250 # Create context with appropriate data handling
251 # Use SINGLE processing mode as default
252 processing_mode = ProcessingMode.SINGLE
254 context = ContextFactory.create_context(
255 self.fsm,
256 data,
257 data_mode=processing_mode
258 )
260 # Set initial state if provided
261 if initial_state:
262 context.set_state(initial_state)
263 else:
264 # Find and set initial state using shared helper
265 initial_state = self._find_initial_state()
266 if initial_state:
267 context.set_state(initial_state)
269 # Update state instance using shared helper
270 if context.current_state:
271 self._update_state_instance(context, context.current_state)
273 # Register custom functions if any
274 if self._custom_functions:
275 if not hasattr(self.fsm, 'function_registry'):
276 self.fsm.function_registry = {}
277 self.fsm.function_registry.update(self._custom_functions)
279 return context
281 @asynccontextmanager
282 async def execution_context(
283 self,
284 data: dict[str, Any] | Record,
285 data_mode: DataHandlingMode = DataHandlingMode.COPY,
286 initial_state: str | None = None
287 ):
288 """Create an execution context for manual control.
290 Args:
291 data: Initial data
292 data_mode: Data handling mode
293 initial_state: Starting state name
295 Yields:
296 ExecutionContext for manual execution
297 """
298 # Create context using factory
299 context = ContextFactory.create_context(
300 fsm=self.fsm,
301 data=data,
302 initial_state=initial_state,
303 data_mode=ProcessingMode.SINGLE,
304 resource_manager=self._resource_manager
305 )
307 # Set transaction manager if configured
308 if self._transaction_manager:
309 context.transaction_manager = self._transaction_manager # type: ignore[unreachable]
311 # Get the state instance for the hook
312 state_instance = context.current_state_instance
313 if not state_instance:
314 # Create state instance if not set by factory
315 state_instance = self.fsm.create_state_instance(
316 context.current_state, # type: ignore
317 context.data.copy() if isinstance(context.data, dict) else {}
318 )
319 context.current_state_instance = state_instance
321 # Call hook with StateInstance
322 if self._hooks.on_state_enter:
323 await self._hooks.on_state_enter(state_instance)
325 try:
326 yield context
327 finally:
328 # Cleanup
329 if self._hooks.on_state_exit:
330 await self._hooks.on_state_exit(state_instance)
331 await self._resource_manager.cleanup()
333 async def step(
334 self,
335 context: ExecutionContext,
336 arc_name: str | None = None
337 ) -> StateInstance | None:
338 """Execute a single transition step.
340 Args:
341 context: Execution context
342 arc_name: Optional specific arc to follow
344 Returns:
345 New state instance or None if no transition
346 """
347 # Store the current state before the transition
348 state_before = context.current_state
350 # Use the async execution engine to execute one step
351 # This ensures consistent execution logic across all FSM types
352 _success, _result = await self._async_engine.execute(
353 context=context,
354 data=None, # Don't override context data
355 max_transitions=1, # Execute exactly one transition
356 arc_name=arc_name # Pass arc_name for filtering
357 )
359 # Check if we actually transitioned to a new state
360 if context.current_state != state_before and context.current_state is not None:
361 # Update state instance using shared helper
362 self._update_state_instance(context, context.current_state)
363 new_state = context.current_state_instance
365 # Track in history using shared helper
366 if context.current_state is not None:
367 self._record_history_step(context.current_state, arc_name, context)
369 # Add to trace using shared helper (we need to adjust the helper slightly)
370 if self.execution_mode == ExecutionMode.TRACE:
371 self._trace_buffer.append({
372 'from': state_before,
373 'to': context.current_state,
374 'arc': arc_name or 'transition',
375 'data': context.data
376 })
378 # Call state enter hook (async version)
379 if self._hooks.on_state_enter:
380 await self._hooks.on_state_enter(new_state)
382 return new_state
384 # No transition occurred
385 return None
387 async def run_until_breakpoint(
388 self,
389 context: ExecutionContext,
390 max_steps: int = 1000
391 ) -> StateInstance | None:
392 """Run execution until a breakpoint is hit.
394 Args:
395 context: Execution context
396 max_steps: Maximum steps to execute (safety limit)
398 Returns:
399 State instance where execution stopped
400 """
401 for _ in range(max_steps):
402 # Check if current state is a breakpoint
403 if context.current_state in self._breakpoints:
404 return context.current_state_instance
406 # Step to next state
407 new_state = await self.step(context)
409 # Check if we reached an end state or no transition occurred
410 if not new_state or self._is_at_end_state(context):
411 return context.current_state_instance
413 # Hit max steps limit
414 return context.current_state_instance
416 async def trace_execution(
417 self,
418 data: dict[str, Any] | Record,
419 initial_state: str | None = None
420 ) -> list[dict[str, Any]]:
421 """Execute with full tracing enabled.
423 Args:
424 data: Input data
425 initial_state: Optional starting state
427 Returns:
428 List of trace entries
429 """
430 self.execution_mode = ExecutionMode.TRACE
431 self._trace_buffer.clear()
433 async with self.execution_context(data, initial_state=initial_state) as context:
434 # Run to completion
435 while True:
436 new_state = await self.step(context)
437 if not new_state or new_state.definition.is_end:
438 break
440 return self._trace_buffer
442 async def profile_execution(
443 self,
444 data: dict[str, Any] | Record,
445 initial_state: str | None = None
446 ) -> dict[str, Any]:
447 """Execute with performance profiling.
449 Args:
450 data: Input data
451 initial_state: Optional starting state
453 Returns:
454 Profiling data
455 """
456 import time
458 self.execution_mode = ExecutionMode.PROFILE
459 self._profile_data.clear()
461 async with self.execution_context(data, initial_state=initial_state) as context:
462 start_time = time.time()
463 transitions = 0
465 # Track per-state timing
466 state_times = {}
467 state_start = time.time()
469 while True:
470 # Get current state name
471 if isinstance(context.current_state, str):
472 current_state_name = context.current_state
473 else:
474 current_state_name = context.current_state if context.current_state else "unknown"
476 # Step
477 new_state = await self.step(context)
479 # Record state timing
480 state_duration = time.time() - state_start
481 if current_state_name not in state_times:
482 state_times[current_state_name] = []
483 state_times[current_state_name].append(state_duration)
485 if not new_state or (hasattr(new_state, 'definition') and new_state.definition.is_end):
486 break
488 transitions += 1
489 state_start = time.time()
491 total_time = time.time() - start_time
493 # Compute statistics
494 self._profile_data = {
495 'total_time': total_time,
496 'transitions': transitions,
497 'avg_transition_time': total_time / transitions if transitions > 0 else 0,
498 'state_times': {
499 state: {
500 'count': len(times),
501 'total': sum(times),
502 'avg': sum(times) / len(times),
503 'min': min(times),
504 'max': max(times)
505 }
506 for state, times in state_times.items()
507 }
508 }
510 return self._profile_data
512 def get_available_transitions(
513 self,
514 state_name: str
515 ) -> list[dict[str, Any]]:
516 """Get available transitions from a state.
518 Args:
519 state_name: Name of state
521 Returns:
522 List of available transition information
523 """
524 arcs = self.fsm.get_outgoing_arcs(state_name)
525 return [
526 {
527 'name': arc.name,
528 'target': arc.target_state,
529 'has_pre_test': arc.pre_test is not None,
530 'has_transform': arc.transform is not None
531 }
532 for arc in arcs
533 ]
535 def inspect_state(self, state_name: str) -> dict[str, Any]:
536 """Inspect a state's configuration.
538 Args:
539 state_name: Name of state to inspect
541 Returns:
542 State configuration details
543 """
544 state = self.fsm.get_state(state_name)
545 if not state:
546 return {'error': f'State {state_name} not found'}
548 return {
549 'name': state.name,
550 'is_start': self.fsm.is_start_state(state_name),
551 'is_end': self.fsm.is_end_state(state_name),
552 'has_transform': len(state.transform_functions) > 0,
553 'has_validator': len(state.validation_functions) > 0,
554 'resources': [r.name for r in state.resource_requirements] if state.resource_requirements else [],
555 'metadata': state.metadata,
556 'arcs': state.arcs
557 }
559 def visualize_fsm(self) -> str:
560 """Generate a visual representation of the FSM.
562 Returns:
563 GraphViz DOT format string
564 """
565 lines = ['digraph FSM {']
566 lines.append(' rankdir=LR;')
567 lines.append(' node [shape=circle];')
569 # Add states
570 for state in self.fsm.states.values():
571 attrs = []
572 if state.is_start:
573 attrs.append('style=filled')
574 attrs.append('fillcolor=green')
575 elif state.is_end:
576 attrs.append('shape=doublecircle')
577 attrs.append('style=filled')
578 attrs.append('fillcolor=red')
580 if attrs:
581 lines.append(f' {state.name} [{",".join(attrs)}];')
582 else:
583 lines.append(f' {state.name};')
585 # Add arcs
586 for state_name in self.fsm.states:
587 for arc in self.fsm.get_outgoing_arcs(state_name):
588 label = arc.name if arc.name else ""
589 lines.append(f' {state_name} -> {arc.target_state} [label="{label}"];')
591 lines.append('}')
592 return '\n'.join(lines)
594 async def validate_network(self) -> dict[str, Any]:
595 """Validate the FSM network for consistency.
597 Returns:
598 Validation results
599 """
600 issues = []
602 # Check for unreachable states
603 reachable = set()
604 to_visit = [s.name for s in self.fsm.states.values() if s.is_start]
606 while to_visit:
607 state = to_visit.pop(0)
608 if state in reachable:
609 continue
610 reachable.add(state)
612 arcs = self.fsm.get_outgoing_arcs(state)
613 for arc in arcs:
614 if arc.target_state not in reachable:
615 to_visit.append(arc.target_state)
617 unreachable = set(self.fsm.states.keys()) - reachable
618 if unreachable:
619 issues.append({
620 'type': 'unreachable_states',
621 'states': list(unreachable)
622 })
624 # Check for dead ends (non-end states with no outgoing arcs)
625 for state_name, state in self.fsm.states.items():
626 if not state.is_end:
627 arcs = self.fsm.get_outgoing_arcs(state_name)
628 if not arcs:
629 issues.append({
630 'type': 'dead_end',
631 'state': state_name
632 })
634 return {
635 'valid': len(issues) == 0,
636 'issues': issues,
637 'stats': {
638 'total_states': len(self.fsm.states),
639 'reachable_states': len(reachable),
640 'unreachable_states': len(unreachable),
641 'start_states': sum(1 for s in self.fsm.states.values() if s.is_start), # type: ignore
642 'end_states': sum(1 for s in self.fsm.states.values() if s.is_end) # type: ignore
643 }
644 }
646 def get_history(self) -> ExecutionHistory | None:
647 """Get execution history if enabled.
649 Returns:
650 Execution history or None
651 """
652 return self._history
654 async def save_history(self) -> bool:
655 """Save execution history to storage.
657 Returns:
658 True if saved successfully
659 """
660 if self._history and self._storage: # type: ignore[unreachable]
661 return await self._storage.save(self._history) # type: ignore[unreachable]
662 return False
664 async def load_history(self, history_id: str) -> bool:
665 """Load execution history from storage.
667 Args:
668 history_id: History identifier
670 Returns:
671 True if loaded successfully
672 """
673 if self._storage:
674 history = await self._storage.load(history_id) # type: ignore[unreachable]
675 if history:
676 self._history = history
677 return True
678 return False
680 # ========== Shared Helper Methods ==========
681 # These methods contain logic shared between sync and async implementations
683 def _get_available_transitions(
684 self,
685 context: ExecutionContext,
686 arc_name: str | None = None
687 ) -> list:
688 """Get available transitions from current state (shared logic).
690 Args:
691 context: Execution context
692 arc_name: Optional specific arc to filter for
694 Returns:
695 List of available arcs
696 """
697 transitions = []
698 if not context.current_state:
699 return transitions
701 # Get arcs from current state
702 arcs = self.fsm.get_outgoing_arcs(context.current_state)
704 for arc in arcs:
705 # Filter by arc name if specified
706 if arc_name and arc.name != arc_name:
707 continue
709 # Check arc condition
710 if arc.pre_test:
711 # Get function registry
712 registry = getattr(self.fsm, 'function_registry', {})
713 if hasattr(registry, 'functions'):
714 functions = registry.functions
715 else:
716 functions = registry
718 # Check in registry and custom functions
719 test_func = functions.get(arc.pre_test) or self._custom_functions.get(arc.pre_test)
720 if test_func:
721 try:
722 if test_func(context.data, context):
723 transitions.append(arc)
724 except Exception:
725 pass
726 else:
727 # No condition, arc is always available
728 transitions.append(arc)
730 return transitions
732 def _execute_arc_transform(
733 self,
734 arc,
735 context: ExecutionContext
736 ) -> tuple[bool, Any]:
737 """Execute arc transform function (shared logic).
739 Args:
740 arc: Arc with potential transform
741 context: Execution context
743 Returns:
744 Tuple of (success, result_or_error)
745 """
746 if not arc.transform:
747 return True, context.data
749 # Get function registry
750 registry = getattr(self.fsm, 'function_registry', {})
751 if hasattr(registry, 'functions'):
752 functions = registry.functions
753 else:
754 functions = registry
756 # Look for transform in registry or custom functions
757 transform_func = functions.get(arc.transform) or self._custom_functions.get(arc.transform)
759 if transform_func:
760 try:
761 result = transform_func(context.data, context)
762 return True, result
763 except Exception as e:
764 return False, str(e)
766 return True, context.data
768 def _update_state_instance(
769 self,
770 context: ExecutionContext,
771 state_name: str
772 ) -> None:
773 """Update the current state instance in context (shared logic).
775 Args:
776 context: Execution context
777 state_name: Name of the new state
778 """
779 state_def = self.fsm.states.get(state_name)
780 if state_def:
781 context.current_state_instance = StateInstance(
782 definition=state_def,
783 data=context.data
784 )
785 # Mark if it's an end state
786 context.metadata['is_end_state'] = state_def.is_end
788 def _is_at_end_state(self, context: ExecutionContext) -> bool:
789 """Check if context is at an end state (shared logic).
791 Args:
792 context: Execution context
794 Returns:
795 True if at an end state
796 """
797 if not context.current_state:
798 return False
800 state = self.fsm.states.get(context.current_state)
801 if state:
802 return state.is_end
804 return context.metadata.get('is_end_state', False)
806 def _record_trace_entry(
807 self,
808 from_state: str,
809 to_state: str,
810 arc_name: str | None,
811 context: ExecutionContext
812 ) -> None:
813 """Record a trace entry if in trace mode (shared logic).
815 Args:
816 from_state: State transitioning from
817 to_state: State transitioning to
818 arc_name: Name of arc taken
819 context: Execution context
820 """
821 if self.execution_mode == ExecutionMode.TRACE:
822 self._trace_buffer.append({
823 'from_state': from_state,
824 'to_state': to_state,
825 'transition': arc_name or f"{from_state}->{to_state}",
826 'data': context.get_data_snapshot(),
827 'timestamp': time.time()
828 })
830 def _record_history_step(
831 self,
832 state_name: str,
833 arc_name: str | None,
834 context: ExecutionContext
835 ) -> None:
836 """Record a history step if history is enabled (shared logic).
838 Args:
839 state_name: Current state name
840 arc_name: Arc taken
841 context: Execution context
842 """
843 if self._history:
844 step = self._history.add_step( # type: ignore[unreachable]
845 state_name=state_name,
846 network_name=getattr(context, 'network_name', 'main'),
847 data=context.data
848 )
849 step.complete(arc_taken=arc_name or 'transition')
851 def _call_hook_sync(
852 self,
853 hook_name: str,
854 *args: Any
855 ) -> None:
856 """Call a hook synchronously if it exists (shared logic).
858 Args:
859 hook_name: Name of hook attribute
860 args: Arguments to pass to hook
861 """
862 hook = getattr(self._hooks, hook_name, None)
863 if hook:
864 try:
865 hook(*args)
866 except Exception:
867 pass # Silently ignore hook errors
869 def _find_initial_state(self) -> str | None:
870 """Find the initial state in the FSM (shared logic).
872 Returns:
873 Name of initial state or None
874 """
875 for state_name, state in self.fsm.states.items():
876 if state.is_start:
877 return state_name
878 return None
880 # ========== Synchronous Execution Methods ==========
882 def execute_step_sync(
883 self,
884 context: ExecutionContext,
885 arc_name: str | None = None
886 ) -> StepResult:
887 """Execute a single transition step synchronously.
889 Args:
890 context: Execution context
891 arc_name: Optional specific arc to follow
893 Returns:
894 StepResult with transition details
895 """
896 start_time = time.time()
897 from_state = context.current_state or "initial"
898 data_before = context.get_data_snapshot()
900 try:
901 # Initialize state if needed
902 if not context.current_state:
903 initial_state = self._find_initial_state()
904 if initial_state:
905 context.set_state(initial_state)
906 self._update_state_instance(context, initial_state)
907 # Execute transforms for initial state
908 if hasattr(self._engine, '_execute_state_transforms'):
909 self._engine._execute_state_transforms(context, initial_state)
910 else:
911 return StepResult(
912 from_state=from_state,
913 to_state=from_state,
914 transition="error",
915 data_before=data_before,
916 data_after=context.get_data_snapshot(),
917 duration=time.time() - start_time,
918 success=False,
919 error="No initial state found"
920 )
922 # Use shared logic to get transitions
923 transitions = self._get_available_transitions(context, arc_name)
925 if not transitions:
926 # No transitions available
927 return StepResult(
928 from_state=from_state,
929 to_state=from_state,
930 transition="none",
931 data_before=data_before,
932 data_after=context.get_data_snapshot(),
933 duration=time.time() - start_time,
934 success=True,
935 is_complete=self._is_at_end_state(context)
936 )
938 # Take first valid transition (could be enhanced with strategy selection)
939 arc = transitions[0]
941 # Execute arc transform using shared logic
942 success, result = self._execute_arc_transform(arc, context)
943 if success:
944 context.data = result
945 else:
946 return StepResult(
947 from_state=from_state,
948 to_state=from_state,
949 transition=arc.name or "error",
950 data_before=data_before,
951 data_after=context.get_data_snapshot(),
952 duration=time.time() - start_time,
953 success=False,
954 error=result
955 )
957 # Update state
958 context.set_state(arc.target_state)
959 self._update_state_instance(context, arc.target_state)
961 # Execute state transforms when entering the new state
962 # This is critical for sync execution to match async behavior
963 if hasattr(self._engine, '_execute_state_transforms'):
964 self._engine._execute_state_transforms(context, arc.target_state)
966 # Check if we hit a breakpoint
967 at_breakpoint = arc.target_state in self._breakpoints
969 # Record in trace buffer if in trace mode
970 self._record_trace_entry(from_state, arc.target_state, arc.name, context)
972 # Record in history if enabled
973 self._record_history_step(arc.target_state, arc.name, context)
975 # Call hooks if configured
976 self._call_hook_sync('on_state_exit', from_state)
977 self._call_hook_sync('on_state_enter', arc.target_state)
979 return StepResult(
980 from_state=from_state,
981 to_state=arc.target_state,
982 transition=arc.name or f"{from_state}->{arc.target_state}",
983 data_before=data_before,
984 data_after=context.get_data_snapshot(),
985 duration=time.time() - start_time,
986 success=True,
987 at_breakpoint=at_breakpoint,
988 is_complete=self._is_at_end_state(context)
989 )
991 except Exception as e:
992 self._call_hook_sync('on_error', e)
994 return StepResult(
995 from_state=from_state,
996 to_state=from_state,
997 transition="error",
998 data_before=data_before,
999 data_after=context.get_data_snapshot(),
1000 duration=time.time() - start_time,
1001 success=False,
1002 error=str(e)
1003 )
1005 def run_until_breakpoint_sync(
1006 self,
1007 context: ExecutionContext,
1008 max_steps: int = 1000
1009 ) -> StateInstance | None:
1010 """Run execution until a breakpoint is hit (synchronous).
1012 Args:
1013 context: Execution context
1014 max_steps: Maximum steps to execute
1016 Returns:
1017 State instance where execution stopped
1018 """
1019 for _ in range(max_steps):
1020 # Check if at breakpoint
1021 if context.current_state in self._breakpoints:
1022 return context.current_state_instance
1024 # Execute step
1025 result = self.execute_step_sync(context)
1027 # Check for completion or error
1028 if not result.success or result.is_complete:
1029 return context.current_state_instance
1031 # Check if stuck
1032 if result.from_state == result.to_state and result.transition == "none":
1033 return context.current_state_instance
1035 return context.current_state_instance
1037 def trace_execution_sync(
1038 self,
1039 data: dict[str, Any] | Record,
1040 initial_state: str | None = None,
1041 max_steps: int = 1000
1042 ) -> list[dict[str, Any]]:
1043 """Execute with full tracing enabled (synchronous).
1045 Args:
1046 data: Input data
1047 initial_state: Optional starting state
1048 max_steps: Maximum steps to execute
1050 Returns:
1051 List of trace entries
1052 """
1053 self.execution_mode = ExecutionMode.TRACE
1054 self._trace_buffer.clear()
1056 context = self.create_context(data, initial_state=initial_state)
1058 for _ in range(max_steps):
1059 # Execute step (trace recording happens inside execute_step_sync)
1060 result = self.execute_step_sync(context)
1062 # Check termination conditions
1063 if not result.success or result.is_complete:
1064 break
1066 if result.from_state == result.to_state and result.transition == "none":
1067 break
1069 return self._trace_buffer
1071 def profile_execution_sync(
1072 self,
1073 data: dict[str, Any] | Record,
1074 initial_state: str | None = None,
1075 max_steps: int = 1000
1076 ) -> dict[str, Any]:
1077 """Execute with performance profiling (synchronous).
1079 Args:
1080 data: Input data
1081 initial_state: Optional starting state
1082 max_steps: Maximum steps to execute
1084 Returns:
1085 Profiling data
1086 """
1087 self.execution_mode = ExecutionMode.PROFILE
1088 self._profile_data.clear()
1090 context = self.create_context(data, initial_state=initial_state)
1092 start_time = time.time()
1093 transitions = 0
1094 state_times = {}
1095 transition_times = []
1097 for _ in range(max_steps):
1098 state_start = time.time()
1099 current_state = context.current_state
1101 # Execute step
1102 result = self.execute_step_sync(context)
1104 # Record timings
1105 if current_state:
1106 if current_state not in state_times:
1107 state_times[current_state] = []
1108 state_times[current_state].append(time.time() - state_start)
1110 if result.success and result.from_state != result.to_state:
1111 transition_times.append(result.duration)
1112 transitions += 1
1114 # Check termination
1115 if not result.success or result.is_complete:
1116 break
1118 if result.from_state == result.to_state and result.transition == "none":
1119 break
1121 # Calculate statistics
1122 self._profile_data = {
1123 'total_time': time.time() - start_time,
1124 'transitions': transitions,
1125 'states_visited': len(state_times),
1126 'avg_transition_time': sum(transition_times) / len(transition_times) if transition_times else 0,
1127 'state_times': {
1128 state: {
1129 'count': len(times),
1130 'total': sum(times),
1131 'avg': sum(times) / len(times),
1132 'min': min(times),
1133 'max': max(times)
1134 }
1135 for state, times in state_times.items()
1136 },
1137 'final_state': context.current_state,
1138 'final_data': context.get_data_snapshot()
1139 }
1141 return self._profile_data
1144class FSMDebugger:
1145 """Interactive debugger for FSM execution (fully synchronous)."""
1147 def __init__(self, fsm: AdvancedFSM):
1148 """Initialize debugger.
1150 Args:
1151 fsm: Advanced FSM instance to debug
1152 """
1153 self.fsm = fsm
1154 self.context: ExecutionContext | None = None
1155 self.watch_vars: dict[str, Any] = {}
1156 self.command_history: list[str] = []
1157 self.step_count: int = 0
1158 self.execution_history: list[StepResult] = []
1160 @property
1161 def current_state(self) -> str | None:
1162 """Get the current state name."""
1163 if not self.context:
1164 return None
1165 return self.context.get_current_state()
1167 @property
1168 def watches(self) -> dict[str, Any]:
1169 """Get current watch variable values."""
1170 return self.watch_vars.copy()
1172 def start(
1173 self,
1174 data: dict[str, Any] | Record,
1175 initial_state: str | None = None
1176 ) -> None:
1177 """Start debugging session (synchronous).
1179 Args:
1180 data: Initial data
1181 initial_state: Optional starting state
1182 """
1183 self.context = self.fsm.create_context(data, initial_state=initial_state)
1184 self.step_count = 0
1185 self.execution_history.clear()
1187 print(f"Debugger started at state: {self.context.current_state or 'initial'}")
1188 print(f"Data: {self.context.get_data_snapshot()}")
1190 def step(self) -> StepResult:
1191 """Execute single step and return detailed result.
1193 Returns:
1194 StepResult with transition details
1195 """
1196 if not self.context:
1197 print("No active debugging session. Call start() first.")
1198 return StepResult(
1199 from_state="none",
1200 to_state="none",
1201 transition="error",
1202 success=False,
1203 error="No active debugging session"
1204 )
1206 result = self.fsm.execute_step_sync(self.context)
1207 self.step_count += 1
1208 self.execution_history.append(result)
1210 # Print step information
1211 if result.success:
1212 if result.from_state == result.to_state and result.transition == "none":
1213 print(f"Step {self.step_count}: No transition available from '{result.from_state}'")
1214 else:
1215 print(f"Step {self.step_count}: {result.from_state} -> {result.to_state} via '{result.transition}'")
1217 if result.at_breakpoint:
1218 print("*** Hit breakpoint ***")
1220 if result.is_complete:
1221 print("*** Reached end state ***")
1222 else:
1223 print(f"Step {self.step_count}: Error - {result.error}")
1225 # Check watches
1226 self._check_watches()
1228 return result
1230 def continue_to_breakpoint(self) -> StateInstance | None:
1231 """Continue execution until a breakpoint is hit.
1233 Returns:
1234 State instance where execution stopped
1235 """
1236 if not self.context:
1237 print("No active debugging session")
1238 return None
1240 print(f"Continuing from state: {self.context.current_state}")
1241 final_state = self.fsm.run_until_breakpoint_sync(self.context)
1243 if final_state:
1244 print(f"Stopped at: {self.context.current_state}")
1245 if self.context.current_state in self.fsm._breakpoints:
1246 print("*** At breakpoint ***")
1247 if self.context.is_complete():
1248 print("*** Execution complete ***")
1250 return final_state
1252 def inspect(self, path: str = "") -> Any:
1253 """Inspect data at path.
1255 Args:
1256 path: Dot-separated path to data field (empty for all data)
1258 Returns:
1259 Value at path
1260 """
1261 if not self.context:
1262 print("No active debugging session")
1263 return None
1265 data = self.context.data
1267 if not path:
1268 return data
1270 # Navigate path
1271 for key in path.split('.'):
1272 if isinstance(data, dict):
1273 data = data.get(key)
1274 elif hasattr(data, key):
1275 data = getattr(data, key)
1276 else:
1277 return None
1278 return data
1280 def watch(self, name: str, path: str) -> None:
1281 """Add a watch expression.
1283 Args:
1284 name: Watch name
1285 path: Data path to watch
1286 """
1287 self.watch_vars[name] = path
1288 value = self.inspect(path)
1289 print(f"Watch '{name}' added: {path} = {value}")
1291 def unwatch(self, name: str) -> None:
1292 """Remove a watch expression.
1294 Args:
1295 name: Watch name to remove
1296 """
1297 if name in self.watch_vars:
1298 del self.watch_vars[name]
1299 print(f"Watch '{name}' removed")
1301 def _check_watches(self) -> None:
1302 """Check and print changed watch values."""
1303 if not self.watch_vars:
1304 return
1306 for name, path in self.watch_vars.items():
1307 value = self.inspect(path)
1308 print(f" Watch '{name}': {path} = {value}")
1310 def print_watches(self) -> None:
1311 """Print all watch values."""
1312 if not self.watch_vars:
1313 print("No watches set")
1314 return
1316 for name, path in self.watch_vars.items():
1317 value = self.inspect(path)
1318 print(f"{name}: {path} = {value}")
1320 def print_state(self) -> None:
1321 """Print current state information."""
1322 if not self.context:
1323 print("No active debugging session")
1324 return
1326 print("\n=== State Information ===")
1327 print(f"Current State: {self.context.current_state}")
1328 print(f"Previous State: {self.context.previous_state}")
1329 print(f"Is Complete: {self.context.is_complete()}")
1330 print("\nData:")
1331 data = self.context.get_data_snapshot()
1332 for key, value in data.items():
1333 print(f" {key}: {value}")
1335 # Print available transitions
1336 transitions = self.fsm._get_available_transitions(self.context)
1337 if transitions:
1338 print("\nAvailable Transitions:")
1339 for arc in transitions:
1340 print(f" - {arc.name or 'unnamed'} -> {arc.target_state}")
1341 else:
1342 if self.context.is_complete():
1343 print("\nNo transitions (end state)")
1344 else:
1345 print("\nNo available transitions")
1347 def inspect_current_state(self) -> dict[str, Any]:
1348 """Get detailed information about current state.
1350 Returns:
1351 Dictionary with state details
1352 """
1353 if not self.context:
1354 return {"error": "No active debugging session"}
1356 return {
1357 'state': self.context.current_state,
1358 'previous_state': self.context.previous_state,
1359 'data': self.context.get_data_snapshot(),
1360 'is_complete': self.context.is_complete(),
1361 'step_count': self.step_count,
1362 'at_breakpoint': self.context.current_state in self.fsm._breakpoints,
1363 'available_transitions': [
1364 {'name': arc.name, 'target': arc.target_state}
1365 for arc in self.fsm._get_available_transitions(self.context)
1366 ]
1367 }
1369 def get_history(self, limit: int = 10) -> list[StepResult]:
1370 """Get recent execution history.
1372 Args:
1373 limit: Maximum number of steps to return
1375 Returns:
1376 List of recent step results
1377 """
1378 return self.execution_history[-limit:]
1380 def reset(self, data: dict[str, Any] | Record | None = None) -> None:
1381 """Reset debugger with new data.
1383 Args:
1384 data: New data (uses current data if None)
1385 """
1386 if data is None and self.context:
1387 data = self.context.data
1389 if data is None:
1390 print("No data available for reset")
1391 return
1393 self.start(data)
1396def create_advanced_fsm(
1397 config: str | Path | dict[str, Any] | FSM,
1398 custom_functions: dict[str, Callable] | None = None,
1399 **kwargs
1400) -> AdvancedFSM:
1401 """Factory function to create an AdvancedFSM instance.
1403 Args:
1404 config: Configuration, FSM instance, or path
1405 custom_functions: Optional custom functions to register
1406 **kwargs: Additional arguments
1408 Returns:
1409 Configured AdvancedFSM instance
1410 """
1411 if isinstance(config, FSM):
1412 fsm = config
1413 else:
1414 from ..config.builder import FSMBuilder
1415 from ..config.loader import ConfigLoader
1417 loader = ConfigLoader()
1419 if isinstance(config, (str, Path)):
1420 config_obj = loader.load_from_file(str(config))
1421 else:
1422 # Load from dict
1423 config_obj = loader.load_from_dict(config)
1425 builder = FSMBuilder()
1427 # Register custom functions if provided
1428 if custom_functions:
1429 for name, func in custom_functions.items():
1430 builder.register_function(name, func)
1432 fsm = builder.build(config_obj)
1434 return AdvancedFSM(fsm, **kwargs)