diff --git a/README.md b/README.md index 53a1262..c36685b 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,26 @@ QUAlibrate-Core is also automatically installed as part of QUAlibrate: pip install qualibrate ``` +## Testing + +To run the test suite: + +```bash +poetry run pytest +``` + +For verbose output: + +```bash +poetry run pytest -v +``` + +To run specific test modules: + +```bash +poetry run pytest tests/unit/test_runnables/test_run_action/ +``` + ## License QUAlibrate-Core is licensed under the BSD-3 license. See the [LICENSE](https://github.com/qua-platform/qualibrate-core/blob/main/LICENSE) file for more details. diff --git a/qualibrate/models/run_summary/run_error.py b/qualibrate/models/run_summary/run_error.py index 1071973..f00c73b 100644 --- a/qualibrate/models/run_summary/run_error.py +++ b/qualibrate/models/run_summary/run_error.py @@ -7,3 +7,5 @@ class RunError(BaseModel): error_class: str message: str traceback: list[str] + details_headline: str | None = None + details: str | None = None diff --git a/qualibrate/qualibration_node.py b/qualibrate/qualibration_node.py index 2be93f3..08f180a 100644 --- a/qualibrate/qualibration_node.py +++ b/qualibrate/qualibration_node.py @@ -85,6 +85,88 @@ NodeMachineType = TypeVar("NodeMachineType", bound=MachineProtocol) +def simplify_traceback( + tb: Any, # types.TracebackType + node_filepath: Path, +) -> list[str]: + """ + Simplify traceback to show only frames from the user's node code onwards. + + This removes framework overhead while preserving: + - User's node code (action function entry point) + - Subroutines called from the node (nested calls) + - Libraries called from the node + - Sub-libraries called by those libraries + + The algorithm finds the entry point into user code by looking for the + first node file frame AFTER any action framework code. This handles: + - Action errors with subroutines: Includes entire call chain within node + - Action errors without subroutines: Starts from action function + - Body errors: Starts from node body code + - Import errors: Starts from import-time code + + Args: + tb: The exception's traceback + node_filepath: Path to the node file being executed + + Returns: + List of formatted traceback strings + """ + # Get all traceback frames + all_frames = traceback.extract_tb(tb) + + # Find ALL frames in the node file + node_filepath_str = str(node_filepath) + node_frame_indices = [ + idx + for idx, frame in enumerate(all_frames) + if frame.filename == node_filepath_str + ] + + if not node_frame_indices: + # Node file not in traceback (error during framework code + # before node execution). Keep the full traceback + return traceback.format_list(all_frames) + + # Find ALL frames in action framework code + action_framework_indices = [ + idx + for idx, frame in enumerate(all_frames) + if "action_manager.py" in frame.filename + or "action.py" in frame.filename + ] + + if not action_framework_indices: + # No action framework code - this is a node body error + # (not inside an action) or import error + # Start from the first node file occurrence + start_idx = node_frame_indices[0] + else: + # Find the first node file frame AFTER the last action framework frame + # This is the entry point into user code (action function) + last_framework_idx = max(action_framework_indices) + + node_frames_after_framework = [ + idx for idx in node_frame_indices if idx > last_framework_idx + ] + + # Start from the first node frame after framework code + # This captures the action function + all nested calls + # Fallback: use first node frame + # (unlikely edge case - shouldn't happen in normal execution) + start_idx = ( + node_frames_after_framework[0] + if node_frames_after_framework + else node_frame_indices[0] + ) + + # Extract relevant frames from start point onwards + relevant_frames = all_frames[start_idx:] + + # Format the frames + return traceback.format_list(relevant_frames) + + class QualibrationNode( QRunnable[ParametersType, ParametersType], Generic[ParametersType, NodeMachineType], @@ -656,6 +738,170 @@ def _post_run( logger.debug(f"Node run summary {self.run_summary}") return self.run_summary + def _extract_source_snippet(self, tb: Any) -> str | None: + """ + Extract source code snippet from the node file where error occurred. + + Returns the relevant lines from the node file with line numbers, + or None if source cannot be read. + + Uses LAST occurrence strategy - finds the deepest point in the node file + where the error occurred (could be in a subroutine, action, or body). + + Args: + tb: The exception's traceback + + Returns: + Formatted source snippet with line numbers and error marker, + or None if source cannot be read + """ + try: + if not self.filepath: + return None + # Find the LAST frame in the node file + # (deepest point where error occurred) + # Note: This is different from simplify_traceback + # - we want the actual error location + all_frames = traceback.extract_tb(tb) + node_filepath_str = str(self.filepath) + + node_frame_indices = [ + idx + for idx, frame in enumerate(all_frames) + if frame.filename == node_filepath_str + ] + + if not node_frame_indices: + return None + + # Get the LAST occurrence + # - this is where the error actually occurred + # (could be in a subroutine, action function, or body code) + error_frame = all_frames[node_frame_indices[-1]] + error_lineno = error_frame.lineno + if not error_lineno: + return None + + # Read the source file + with open(self.filepath) as f: + lines = f.readlines() + + # Extract 2 lines before and 2 lines after (5 lines total) + # -3 because lineno is 1-indexed + start_line = max(0, error_lineno - 3) + end_line = min(len(lines), error_lineno + 2) + + snippet_lines = [] + for i in range(start_line, end_line): + line_num = i + 1 + line_text = lines[i].rstrip() + marker = ( + " # <- Error occurred here" + if line_num == error_lineno + else "" + ) + snippet_lines.append(f"{line_num:4d}: {line_text}{marker}") + + return "\n".join(snippet_lines) + except Exception: + # If we can't read the source, just return None + return None + + def _generate_error_headline(self, ex: Exception) -> str: + """ + Generate a headline describing the error. + + Returns a string like: + - "KeyError in action 'execute_qua_program'" + - "ValueError in action 'process_data'" + - "RuntimeError in node body" + - "ImportError in node initialization" + + Args: + ex: The exception that was raised + + Returns: + Formatted headline string + """ + failed_action = self._action_manager.failed_action + + if failed_action: + return f"Failed action '{failed_action}'" + else: + # Determine if it's in node body or during initialization + # If we have any actions registered, it's likely in the body + # Otherwise, it's during initialization + location = ( + "node initialization (outside of actions)" + if self._action_manager.actions + else "node body" + ) + return f"Failure in {location}" + + def _generate_error_details( + self, ex: Exception, simplified_tb: list[str] + ) -> str: + """ + Generate detailed error context including action history, + source snippet, and simplified traceback. + + Returns a simple markdown string with: + - Failed action name (if applicable) + - Source code snippet from node file + - Simplified traceback + - List of successfully completed actions + - List of skipped actions + - Error type and message + + Uses simple markdown compatible with React renderers + (no nested code blocks or emojis). + + Args: + ex: The exception that was raised + simplified_tb: Simplified traceback (list of formatted strings) + + Returns: + Formatted details string (simple markdown) + """ + lines = [] + + # Source code snippet (only from node file) + source_snippet = self._extract_source_snippet(ex.__traceback__) + if source_snippet: + lines.append("Source Code:") + # Add snippet lines with indentation + for line in source_snippet.split("\n"): + lines.append(f" {line}") + lines.append("") + + # Simplified traceback + if simplified_tb: + lines.append("Traceback:") + # Add traceback lines with minimal indentation + for tb_line in simplified_tb: + # Remove extra whitespace and format consistently + lines.append(f" {tb_line.rstrip()}") + lines.append("") + + # Action history (only if actions were used) + completed = self._action_manager.completed_actions + skipped = self._action_manager.skipped_actions + + if completed or skipped: + if completed: + lines.append("Completed actions (in order):") + for action in completed: + lines.append(f" - {action}") + lines.append("") + + if skipped: + lines.append("Skipped actions:") + for action in skipped: + lines.append(f" - {action}") + lines.append("") + + return "\n".join(lines) + def run( self, interactive: bool = False, @@ -721,11 +967,23 @@ def run( self.__class__.active_node = self self._action_manager.skip_actions = skip_actions + # Reset action execution history for this run + self._action_manager.completed_actions.clear() + self._action_manager.skipped_actions.clear() + self._action_manager.failed_action = None + self.run_node_file(self.filepath) except Exception as ex: + # Generate simplified traceback for error details (user-friendly) + simplified_tb = simplify_traceback(ex.__traceback__, self.filepath) + + # This particular RunError is propagated to the frontend run_error = RunError( error_class=ex.__class__.__name__, message=str(ex), + details_headline=self._generate_error_headline(ex), + details=self._generate_error_details(ex, simplified_tb), + # Keep FULL traceback traceback=traceback.format_tb(ex.__traceback__), ) logger.exception(f"Failed to run node {self.name}", exc_info=ex) diff --git a/qualibrate/runnables/run_action/action.py b/qualibrate/runnables/run_action/action.py index f84e475..fe3a00e 100644 --- a/qualibrate/runnables/run_action/action.py +++ b/qualibrate/runnables/run_action/action.py @@ -1,3 +1,28 @@ +""" +Action execution and namespace management for QualibrationNodes. + +This module defines the Action class, which wraps functions that are executed +as part of a QualibrationNode's run lifecycle. Actions have special behavior: + +1. **Namespace Updates**: If an action returns a dict, it's added to + node.namespace, making values available to subsequent actions. + +2. **Interactive Variable Injection**: In interactive mode + (running single nodes, not workflows), returned dict items are + injected into the caller's local scope, allowing users to access + variables without explicit assignment. + +Example: + @node.run_action + def prepare_data(node): + x_data = np.linspace(0, 10, 100) + y_data = np.sin(x_data) + return {"x_data": x_data, "y_data": y_data} + + # In interactive mode, x_data and y_data are now available as local vars + # They're also in node.namespace for use by other actions +""" + import inspect import weakref from collections.abc import Callable, Mapping @@ -23,8 +48,21 @@ class Action: """ - Represents a single action to be run by a - QualibrationNode. It stores the decorated function. + Wraps a function for execution within a QualibrationNode's run cycle. + + An Action represents a single step in a node's execution. It provides: + - Automatic namespace management (returned dicts update node.namespace) + - Interactive variable injection (when running single nodes, not workflows) + - Integration with ActionManager for execution control + - Tracking of the currently executing action + + The function is stored along with a weak reference to the ActionManager + to avoid circular references (ActionManager stores Actions, Actions + reference ActionManager). + + Attributes: + func: The wrapped function that performs the action's work + manager: Weak reference to the ActionManager that owns this action """ def __init__( @@ -32,11 +70,21 @@ def __init__( func: ActionCallableType, manager: "ActionManager", ) -> None: + """ + Initialize an Action with a function and its manager. + + Args: + func: The function to wrap. Should accept (node, *args, **kwargs) + and optionally return a dict to update the namespace + manager: The ActionManager that will control this action's + execution. Stored as a weakref to prevent circular references + """ self.func = func self.manager = weakref.proxy(manager) @property def name(self) -> str: + """Get the action's name (the wrapped function's name).""" return self.func.__name__ def _run_and_update_namespace( @@ -45,9 +93,34 @@ def _run_and_update_namespace( *args: Any, **kwargs: Any, ) -> ActionReturnType | None: + """ + Execute the action and update the node's namespace with results. + + This is the core execution method that: + 1. Calls the wrapped function with the node and any arguments + 2. If the result is a dict, updates node.namespace with its contents + + The namespace update allows data to flow between actions - subsequent + actions can access values stored in the namespace by previous actions. + + Args: + node: The QualibrationNode instance executing this action + *args: Positional arguments to pass to the action function + **kwargs: Keyword arguments to pass to the action function + + Returns: + The result from the action function (typically a dict or None) + """ + # Execute the wrapped function + print(f"Running action {self.name}") result = self.func(node, *args, **kwargs) + print(f"Action {self.name} finished") + + # If the function returned a dict, add it to the node's namespace + # This makes the values available to subsequent actions if isinstance(result, Mapping): node.namespace.update(result) + return result def execute_run_action( @@ -57,28 +130,81 @@ def execute_run_action( **kwargs: Any, ) -> ActionReturnType | None: """ - Executes the stored function with the given node. + Execute the action with full lifecycle management and variable + injection. + + This is the main entry point for action execution. It handles: + 1. Tracking the current action in the manager + 2. Clearing the action label on the node (used for UI updates) + 3. Running the action and updating the namespace + 4. In interactive mode: injecting returned variables into caller's scope + 5. Protection against overwriting pre-defined variables + + The interactive variable injection is the "magic" that makes returned + dict items appear as local variables. + + Args: + node: The QualibrationNode instance executing this action + *args: Positional arguments to pass to the action function + **kwargs: Keyword arguments to pass to the action function + + Returns: + The result from the action function (typically a dict or None) + + Side Effects: + - Sets manager.current_action to track execution + - Updates node.namespace if action returns a dict + - In interactive mode: injects variables into the caller's frame """ + # Track that this action is currently executing self.manager.current_action = self + + # Clear action label (used for UI status display) node.action_label = None - result = self._run_and_update_namespace(node, *args, **kwargs) - node.action_label = None - self.manager.current_action = None - if not is_interactive() or not isinstance(result, Mapping): - return result - stack = inspect.stack() - frame_to_update = get_frame_to_update_from_action(stack) - if frame_to_update is None: - return result - already_defined = set(result.keys()).intersection( - self.manager.predefined_names - ) - if already_defined: - logger.warning( - f"Variables {tuple(already_defined)} after run action " - f"{self.func.__name__} won't be set (already defined)." + + try: + # Execute the action and update the node's namespace + result = self._run_and_update_namespace(node, *args, **kwargs) + + # If not in interactive mode or no dict returned, we're done + if not is_interactive() or not isinstance(result, Mapping): + return result + + # === Interactive Mode: Variable Injection === + # This is where the "magic" happens - we inject the returned + # variables into the caller's local scope + # (when running single nodes, not workflows) + + # Get the call stack to find the correct frame to update + stack = inspect.stack() + frame_to_update = get_frame_to_update_from_action(stack) + + if frame_to_update is None: + # Couldn't determine the correct frame, skip injection + return result + + # Check which variables would overwrite pre-existing names + # predefined_names captured when ActionManager was created + already_defined = set(result.keys()).intersection( + self.manager.predefined_names ) - frame_to_update.f_locals.update( - {k: v for k, v in result.items() if k not in already_defined} - ) - return result + + # Warn about variables that won't be injected (already exist) + if already_defined: + logger.warning( + f"Variables {tuple(already_defined)} after run action " + f"{self.func.__name__} won't be set (already defined)." + ) + + # Inject only new variables (not already defined) into the + # caller's scope. This makes them available as local variables + # in the interactive session + frame_to_update.f_locals.update( + {k: v for k, v in result.items() if k not in already_defined} + ) + + self.manager.current_action = None + return result + except Exception as e: + self.manager.failed_action = self.name # Track which action failed + raise e diff --git a/qualibrate/runnables/run_action/action_manager.py b/qualibrate/runnables/run_action/action_manager.py index 88963a1..fb8265a 100644 --- a/qualibrate/runnables/run_action/action_manager.py +++ b/qualibrate/runnables/run_action/action_manager.py @@ -1,3 +1,34 @@ +""" +Action management and execution control for QualibrationNodes. + +This module provides the ActionManager class, which orchestrates the execution +of actions within a QualibrationNode. Key responsibilities: + +1. **Action Registry**: Maintains a dict of all actions for a node +2. **Execution Control**: Can skip all actions or specific named actions +3. **Decorator Pattern**: Provides @node.run_action decorator that both + registers AND immediately executes actions +4. **Variable Protection**: Captures pre-defined names to prevent overwriting + them during interactive variable injection + +The decorator pattern is unusual: decorating a function with @node.run_action +causes it to execute immediately (unless skip_if=True), rather than just +defining it for later use. + +Example: + node = QualibrationNode(...) + + # This runs immediately when defined (unless skip_actions is set) + @node.run_action + def prepare_data(node): + return {"data": [1, 2, 3]} + + # This is registered but NOT run + @node.run_action(skip_if=True) + def optional_step(node): + return {"optional": "value"} +""" + import inspect from collections.abc import Callable, Sequence from functools import wraps @@ -24,19 +55,60 @@ class ActionManager: """ - Manages run actions for a QualibrationNode. It holds an exit flag - which, once set, prevents further actions from running. + Orchestrates action registration, execution, and control for a node. + + The ActionManager is responsible for: + - Maintaining a registry of all actions (by name) + - Tracking which action is currently executing + - Controlling which actions run via skip_actions mechanism + - Capturing pre-defined variable names to prevent overwrites during + interactive variable injection + + Each QualibrationNode has exactly one ActionManager instance created + during node initialization. + + Attributes: + actions: Dictionary mapping action names to Action instances + current_action: The Action currently being executed (None if idle) + predefined_names: Set of variable names that existed when the manager + was created, used to prevent overwriting them during variable + injection in interactive mode """ def __init__(self) -> None: + """ + Initialize the ActionManager and capture pre-defined variable names. + + The initialization captures all names defined in the calling scope + (locals, globals, builtins) at the time the manager is created. This + snapshot is used later to prevent actions from overwriting these + pre-existing variables during interactive variable injection. + """ + # Registry of all actions for this node self.actions: dict[str, Action] = {} + + # Track the currently executing action (for monitoring/debugging) self.current_action: Action | None = None - self._skip_actions: bool = False - self._skip_actions_names: set[str] = set() + + # Action skipping control + self._skip_actions: bool = False # Skip all actions if True + self._skip_actions_names: set[str] = set() # Skip specific actions + + # Capture pre-defined names to protect during variable injection + # This prevents actions from overwriting existing variables in + # interactive sessions stack = inspect.stack() frame_for_names = get_frame_for_keeping_names_from_manager(stack) self.predefined_names = get_defined_in_frame_names(frame_for_names) + # Action execution history (for error reporting) + # Actions that finished successfully + self.completed_actions: list[str] = [] + # Actions that were skipped + self.skipped_actions: list[str] = [] + # Action that raised an error (if any) + self.failed_action: str | None = None + @property def skip_actions(self) -> bool | Sequence[str]: return self._skip_actions @@ -44,15 +116,19 @@ def skip_actions(self) -> bool | Sequence[str]: @skip_actions.setter def skip_actions(self, to_skip: bool | Sequence[str]) -> None: if isinstance(to_skip, bool): + # Boolean mode: skip all (True) or none (False) self._skip_actions = to_skip - self._skip_actions_names = set() + self._skip_actions_names = set() # Clear specific names return + if isinstance(to_skip, Sequence) and ( len(to_skip) == 0 or all(map(lambda x: isinstance(x, str), to_skip)) ): - self._skip_actions = True - self._skip_actions_names = set(to_skip) + # Sequence mode: skip specific named actions + self._skip_actions = True # Enable skipping + self._skip_actions_names = set(to_skip) # Store names to skip return + raise TypeError( f"Invalid value {to_skip} for skip_actions. " "Possible types: bool, Sequence[str]" @@ -65,17 +141,56 @@ def run_action( *args: Any, **kwargs: Any, ) -> ActionReturnType | None: + """ + Execute a registered action by name, respecting skip settings. + + This method: + 1. Looks up the action in the registry + 2. Checks if it should be skipped + 3. Delegates to action.execute_run_action() if not skipped + + The skip logic: + - If skip_actions is False: run the action + - If skip_actions is True and _skip_actions_names is empty: skip all + - If skip_actions is True and _skip_actions_names has items: skip only + those named actions + + Args: + action_name: The name of the action to run (function name) + node: The QualibrationNode instance executing the action + *args: Positional arguments to pass to the action + **kwargs: Keyword arguments to pass to the action + + Returns: + The result from the action (typically a dict or None), or None + if the action was skipped or not found + """ + # Look up the action in the registry action = self.actions.get(action_name) + if action is None: + # Action not registered - this is unexpected logger.warning(f"Can't run action {action_name} of node {node}") return None + + # Check if this action should be skipped if self._skip_actions and ( + # Skip all actions len(self._skip_actions_names) == 0 + # Skip this specific action or action_name in self._skip_actions_names ): logger.info(f"Skipping action {action_name} of node {node}") + self.skipped_actions.append(action_name) # Track skipped action return None - return action.execute_run_action(node, *args, **kwargs) + + # Execute the action + result = action.execute_run_action(node, *args, **kwargs) + + # Track successful completion + self.completed_actions.append(action_name) + + return result def register_action( self, @@ -85,41 +200,83 @@ def register_action( skip_if: bool = False, ) -> ActionDecoratorType: """ - Returns a decorator that creates an Action instance and executes - it immediately. + Decorator factory for registering and executing node actions. - Supports usage both without parentheses: + This method implements an unusual decorator pattern: when you decorate + a function with @node.run_action, the function is BOTH registered in + the action registry AND executed immediately (unless skip_if=True). - @node.run_action - def action(node): - ... + This "define-and-execute" pattern is designed for linear notebook-style + workflows where you want actions to run as they're defined. + + Supports two usage patterns: + 1. Without parentheses: @node.run_action + - Registers and immediately executes the action + 2. With parentheses: @node.run_action(skip_if=True) + - Registers but does NOT execute the action + + The decorated function is replaced with a wrapper that calls + run_action(), in principle allowing it to be called again later + if needed. + + Args: + node: The QualibrationNode instance that owns this action + func: The function to decorate (None when called with parentheses) + skip_if: If True, register but don't execute + + Returns: + Either: + - The decorator function (if func is None, i.e., called with parens) + - The wrapper function (if func provided, i.e., no parens) - and with parentheses: + Examples: + # Define and execute immediately + @node.run_action + def prepare_data(node): + return {"data": [1, 2, 3]} + # prepare_data has already run at this point! - @node.run_action(skip_if=True) - def action(node): - ... + # Define but don't execute (conditional execution) + @node.run_action(skip_if=some_condition) + def optional_step(node): + return {"result": "value"} - Behavior: - - If skip_if is True, the function is not run. + # Call the action again later + prepare_data() # Runs the action again """ def decorator( f: ActionCallableType, ) -> ActionCallableType: + """Inner decorator that performs registration and execution.""" nonlocal node + + # Create an Action instance wrapping the function action = Action(f, self) action_name = f.__name__ + + # Register the action in the manager's registry node._action_manager.actions[action_name] = action + # Create a wrapper that calls run_action when invoked + # This allows the decorated function to be called later @wraps(f) def wrapper(*args: Any, **kwargs: Any) -> ActionReturnType | None: return self.run_action(action_name, node, *args, **kwargs) + # If skip_if is True, return wrapper WITHOUT executing it + # This allows conditional registration without execution if skip_if: - # Skip this action; do not set exit even if exit=True. + node._action_manager.skipped_actions.append(action_name) return wrapper + + # Execute the action immediately (the unusual behavior!) wrapper() + + # Return the wrapper for potential future calls return wrapper + # Support both @decorator and @decorator() syntax + # If func is None, we were called with parentheses: @node.run_action() + # If func is provided, we were called without: @node.run_action return decorator if func is None else decorator(func) diff --git a/qualibrate/runnables/run_action/utils.py b/qualibrate/runnables/run_action/utils.py index 49efbce..0d71e4f 100644 --- a/qualibrate/runnables/run_action/utils.py +++ b/qualibrate/runnables/run_action/utils.py @@ -1,3 +1,25 @@ +""" +Frame manipulation utilities for interactive variable injection. + +This module contains low-level utilities that enable the "magic" of injecting +variables from action return values into the caller's local scope in interactive +environments (running single nodes, not workflows, or in Jypiter notebooks). + +The core challenge: When an action returns {"x": 1}, how do we make `x` appear +as a local variable in the Jupyter cell that called the action? + +Solution: Use Python's inspect.stack() to walk the call stack, identify the +correct frame (the caller's frame), and directly modify frame.f_locals. + +This is advanced Python introspection and is inherently fragile +- it depends on: +- Specific function names ("wrapper", "run_action", "register_action") +- Specific file names ("action_manager.py", "qualibration_node.py") +- Stack depth remaining consistent + +These functions are ONLY used in interactive mode. +""" + import inspect import sys from types import FrameType @@ -6,37 +28,154 @@ def is_interactive() -> bool: + """ + Detect if Python is running in interactive mode. + + Interactive mode could include: + - Running single nodes, not workflows + - Jupyter notebooks + - IPython shells + - Python REPL (when ps1 is set) + + This detection is used to determine whether to inject variables into the + caller's local scope. In non-interactive (script) mode, variable injection + is skipped for performance and safety. + + Returns: + True if running in interactive mode, False otherwise + + Implementation: + Checks for sys.ps1 (primary prompt string, set in REPL) or + sys.flags.interactive (set when Python started with -i flag or in + interactive shells like IPython/Jupyter) + """ return bool(getattr(sys, "ps1", sys.flags.interactive)) def get_frame_to_update_from_action( stack: list[inspect.FrameInfo], ) -> FrameType | None: + """ + Find the correct stack frame to inject variables into. + + This function navigates the call stack to find the frame where the action + was invoked (typically a Jupyter cell or script). The correct frame depends + on HOW the decorator was used: + + 1. Without parentheses: @node.run_action + - Stack is shallower, frame is at position 3 + 2. With parentheses: @node.run_action(skip_if=True) + - Stack is deeper, need to search for qualibration_node.py::run_action + + Call stack example (without parentheses): + [0] get_frame_to_update_from_action <- we are here + [1] execute_run_action (action.py) + [2] wrapper (action_manager.py) + [3] <- TARGET + + Call stack example (with parentheses): + [0] get_frame_to_update_from_action <- we are here + [1] execute_run_action (action.py) + [2] wrapper (action_manager.py) + [3] run_action (action_manager.py) + [4] run_action (qualibration_node.py) + [5] <- TARGET (found by search) + + Args: + stack: The call stack from inspect.stack() + + Returns: + The FrameType to inject variables into, or None if unable to determine + the correct frame + + Note: + This function is FRAGILE - it relies on specific filenames and function + names remaining consistent. Changes to the decorator implementation or + QualibrationNode.run_action() may break variable injection. + """ + # Determine which decorator usage pattern was used without_args = _registered_without_args(stack) + if without_args is None: + # Could not determine usage pattern, fail safely return None + if not without_args: + # Decorator WITH parentheses: @node.run_action(...) + # Stack is shallower, target frame is at fixed position 3 return stack[3].frame + # Decorator WITHOUT parentheses: @node.run_action + # Need to search for the run_action method in qualibration_node.py for i, frame_info in enumerate(stack): frame = frame_info.frame f_code = frame.f_code + + # Look for the run_action method in QualibrationNode if ( f_code.co_filename.endswith("qualibrate/qualibration_node.py") and f_code.co_name == "run_action" and len(stack) >= i + 1 ): + # The frame AFTER this one is the user's calling frame return stack[i + 1].frame + + # Could not find the expected frame in the stack return None def get_frame_for_keeping_names_from_manager( stack: list[inspect.FrameInfo], ) -> FrameType: + """ + Get the frame to capture pre-defined variable names from. + + When ActionManager is initialized, it captures all variable names that + exist at that point (locals, globals, builtins). This prevents actions + from overwriting these pre-existing variables during interactive injection. + + The correct frame is at position 2 in the stack when __init__ is called: + [0] get_frame_for_keeping_names_from_manager <- we are here + [1] ActionManager.__init__ + [2] QualibrationNode.__init__ <- TARGET + [3] + + Args: + stack: The call stack from inspect.stack() + + Returns: + The FrameType to capture variable names from (typically the node's + __init__ frame) + """ return stack[2].frame def get_defined_in_frame_names(frame: FrameType) -> set[str]: + """ + Extract all variable names defined in a frame. + + This captures the complete namespace visible from a frame, including: + - Local variables (frame.f_locals) + - Global variables (frame.f_globals) + - Built-in functions/variables (frame.f_builtins) + + These names are used to prevent variable injection from overwriting + existing variables in interactive mode. For example, if 'x' already + exists, an action returning {"x": 1} won't overwrite it. + + Args: + frame: The FrameType to extract names from + + Returns: + Set of all variable names defined in or accessible from the frame + + Example: + If a frame has: + - Local: x, y + - Global: np, pd + - Builtin: print, len + Returns: {"x", "y", "np", "pd", "print", "len", ...} + """ return { *frame.f_locals.keys(), *frame.f_globals.keys(), @@ -45,16 +184,67 @@ def get_defined_in_frame_names(frame: FrameType) -> set[str]: def _registered_without_args(stack: list[inspect.FrameInfo]) -> bool | None: + """ + Determine if the decorator was used with or without parentheses. + + This function analyzes the call stack to distinguish between: + - @node.run_action (without args - returns True) + - @node.run_action(...) (with args - returns False) + + The distinction is important because the stack depth differs between the + two cases, affecting which frame we need to inject variables into. + + Detection logic: + - Check stack[1] is the "wrapper" function in action_manager.py + - Check stack[3] (the action call frame): + - If it's "register_action" in action_manager.py: without args (True) + - Otherwise: with args (False) + + Call stack for WITHOUT args (@node.run_action): + [0] _registered_without_args + [1] wrapper (action_manager.py) + [2] execute_run_action + [3] register_action (action_manager.py) + <- KEY: still in register_action + ... + + Call stack for WITH args (@node.run_action(...)): + [0] _registered_without_args + [1] wrapper (action_manager.py) + [2] execute_run_action + [3] run_action (NOT register_action) <- KEY: left register_action + ... + + Args: + stack: The call stack from inspect.stack() + + Returns: + - True: Decorator used without parentheses + - False: Decorator used with parentheses + - None: Could not parse stack (unexpected structure) + + Note: + This is VERY fragile and depends on exact function names and file + structure remaining consistent. + """ + # Verify stack[1] is the wrapper function from action_manager.py wrapper_frame = stack[1].frame wrapper_code = wrapper_frame.f_code + if ( wrapper_code.co_name != "wrapper" or not wrapper_code.co_filename.endswith("action_manager.py") ): + # Unexpected stack structure - fail safely logger.warning("Can't correctly parse stack trace") return None + + # Check stack[3] to see if we're still inside register_action action_call_frame = stack[3].frame action_call_code = action_call_frame.f_code + + # If stack[3] is register_action in action_manager.py, decorator was used + # WITHOUT parentheses (immediate execution path is longer/deeper) return ( action_call_code.co_filename.endswith("action_manager.py") and action_call_code.co_name == "register_action" diff --git a/tests/conftest.py b/tests/conftest.py index d15a22d..c7ce7c7 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,5 +1,6 @@ from collections.abc import Generator from pathlib import Path +from unittest.mock import Mock import pytest import tomli_w @@ -84,3 +85,108 @@ def machine(): }, ) return machine + + +@pytest.fixture +def simple_action_function(): + """Provide a simple function that can be wrapped as an action.""" + + def action_func(node): + """Simple action that returns a dict.""" + return {"result": "success", "value": 42} + + return action_func + + +@pytest.fixture +def action_with_no_return(): + """Provide an action function that returns None.""" + + def action_func(node): + """Action with no return value.""" + node.results = {"computed": True} + # No return statement + + return action_func + + +@pytest.fixture +def action_with_dict_return(): + """Provide an action function that returns a dict value.""" + + def action_func(node): + """Action that returns a dict value.""" + return {"x": 1, "y": 2, "z": 3} + + return action_func + + +@pytest.fixture +def action_with_non_dict_return(): + """Provide an action function that returns a non-dict value.""" + + def action_func(node): + """Action that returns a non-dict value.""" + return "just a string" + + return action_func + + +@pytest.fixture +def action_that_raises(): + """Provide an action function that raises an exception.""" + + def action_func(node): + """Action that raises an error.""" + raise ValueError("Test error from action") + + return action_func + + +@pytest.fixture +def non_interactive_mode(mocker): + """Mock is_interactive to return False (non-interactive mode).""" + mocker.patch( + "qualibrate.runnables.run_action.action.is_interactive", + return_value=False, + ) + yield + + +@pytest.fixture +def mock_action_manager(): + """Provide a mock ActionManager.""" + manager = Mock() + manager.current_action = None + manager.predefined_names = set() + manager.actions = {} + return manager + + +@pytest.fixture +def action_manager(mocker): + """Provide a real ActionManager instance with required patches.""" + from qualibrate.runnables.run_action.action_manager import ActionManager + + mocker.patch("qualibrate.runnables.run_action.action_manager.inspect.stack") + mocker.patch( + "qualibrate.runnables.run_action.action_manager" + ".get_frame_for_keeping_names_from_manager" + ) + mocker.patch( + "qualibrate.runnables.run_action.action_manager" + ".get_defined_in_frame_names", + return_value=set(), + ) + return ActionManager() + + +@pytest.fixture +def mock_node(): + """Provide a mock QualibrationNode.""" + node = Mock() + node.name = "test_node" + node.namespace = {} + node.action_label = None + node._action_manager = None + return node diff --git a/tests/unit/test_runnables/__init__.py b/tests/unit/test_runnables/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit/test_runnables/test_run_action/__init__.py b/tests/unit/test_runnables/test_run_action/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit/test_runnables/test_run_action/test_action.py b/tests/unit/test_runnables/test_run_action/test_action.py new file mode 100644 index 0000000..074cf03 --- /dev/null +++ b/tests/unit/test_runnables/test_run_action/test_action.py @@ -0,0 +1,372 @@ +""" +Tests for Action class (non-interactive mode only). +""" + +from unittest.mock import patch + +import pytest + +from qualibrate.runnables.run_action.action import Action + + +class TestActionNameProperty: + """Tests for the name property.""" + + def test_name_returns_function_name( + self, simple_action_function, mock_action_manager + ): + """Test that name property returns function.__name__.""" + action = Action(simple_action_function, mock_action_manager) + + assert action.name == "action_func" + + def test_name_with_different_function_names(self, mock_action_manager): + """Test name property with various function names.""" + + def first_action(node): + pass + + def second_action(node): + pass + + def action_with_long_descriptive_name(node): + pass + + action1 = Action(first_action, mock_action_manager) + action2 = Action(second_action, mock_action_manager) + action3 = Action(action_with_long_descriptive_name, mock_action_manager) + + assert action1.name == "first_action" + assert action2.name == "second_action" + assert action3.name == "action_with_long_descriptive_name" + + +class TestRunAndUpdateNamespace: + """Tests for the _run_and_update_namespace method.""" + + def test_executes_function(self, mock_action_manager, mock_node): + """Test that _run_and_update_namespace executes the function.""" + executed = {"called": False} + + def test_func(node): + executed["called"] = True + return {"result": "success"} + + action = Action(test_func, mock_action_manager) + action._run_and_update_namespace(mock_node) + + assert executed["called"] is True + + def test_returns_function_result( + self, mock_action_manager, mock_node, simple_action_function + ): + """Test that method returns the function's return value.""" + action = Action(simple_action_function, mock_action_manager) + result = action._run_and_update_namespace(mock_node) + + assert result == {"result": "success", "value": 42} + + def test_updates_namespace_with_dict_return( + self, mock_action_manager, mock_node, action_with_dict_return + ): + """Test that dict returns update node.namespace.""" + + action = Action(action_with_dict_return, mock_action_manager) + action._run_and_update_namespace(mock_node) + + assert mock_node.namespace == {"x": 1, "y": 2, "z": 3} + + def test_no_namespace_update_with_none_return( + self, mock_action_manager, mock_node, action_with_no_return + ): + """Test that no return doesn't update namespace.""" + action = Action(action_with_no_return, mock_action_manager) + action._run_and_update_namespace(mock_node) + + assert mock_node.namespace == {} + + def test_no_namespace_update_with_non_dict_return( + self, mock_action_manager, mock_node, action_with_non_dict_return + ): + """Test that non-dict returns don't update namespace.""" + action = Action(action_with_non_dict_return, mock_action_manager) + action._run_and_update_namespace(mock_node) + + assert mock_node.namespace == {} + + def test_passes_args_to_function(self, mock_action_manager, mock_node): + """Test that args are passed to the wrapped function.""" + received_args = [] + + def test_func(node, *args): + received_args.extend(args) + return {"result": "success"} + + action = Action(test_func, mock_action_manager) + action._run_and_update_namespace(mock_node, "arg1", "arg2", "arg3") + + assert received_args == ["arg1", "arg2", "arg3"] + + def test_passes_kwargs_to_function(self, mock_action_manager, mock_node): + """Test that kwargs are passed to the wrapped function.""" + received_kwargs = {} + + def test_func(node, **kwargs): + received_kwargs.update(kwargs) + return {"result": "success"} + + action = Action(test_func, mock_action_manager) + action._run_and_update_namespace( + mock_node, key1="value1", key2="value2" + ) + + assert received_kwargs == {"key1": "value1", "key2": "value2"} + + def test_namespace_accumulates_across_actions( + self, mock_action_manager, mock_node + ): + """Test that namespace accumulates values from multiple actions.""" + + def action1(node): + return {"a": 1, "b": 2} + + def action2(node): + return {"c": 3, "d": 4} + + action_obj1 = Action(action1, mock_action_manager) + action_obj2 = Action(action2, mock_action_manager) + + action_obj1._run_and_update_namespace(mock_node) + action_obj2._run_and_update_namespace(mock_node) + + assert mock_node.namespace == {"a": 1, "b": 2, "c": 3, "d": 4} + + def test_later_action_can_overwrite_namespace_values( + self, mock_action_manager, mock_node + ): + """Test that later actions can overwrite earlier namespace values.""" + + def action1(node): + return {"x": "first", "y": "original"} + + def action2(node): + return {"x": "second"} # Overwrites x + + action_obj1 = Action(action1, mock_action_manager) + action_obj2 = Action(action2, mock_action_manager) + + action_obj1._run_and_update_namespace(mock_node) + action_obj2._run_and_update_namespace(mock_node) + + assert mock_node.namespace == {"x": "second", "y": "original"} + + +class TestExecuteRunActionNonInteractive: + """Tests for execute_run_action in non-interactive mode.""" + + def test_sets_current_action_during_execution( + self, + simple_action_function, + mock_action_manager, + mock_node, + non_interactive_mode, + ): + """Test that current_action is set during execution.""" + current_action_during_execution = [] + + def tracking_func(node): + current_action_during_execution.append( + mock_action_manager.current_action + ) + return {"result": "success"} + + action = Action(tracking_func, mock_action_manager) + action.execute_run_action(mock_node) + + # Should have been set to self during execution + assert len(current_action_during_execution) == 1 + assert current_action_during_execution[0] is action + + def test_clears_action_label_before_execution( + self, + simple_action_function, + mock_action_manager, + mock_node, + non_interactive_mode, + ): + """Test that action_label is cleared before execution.""" + mock_node.action_label = "previous_label" + + action = Action(simple_action_function, mock_action_manager) + action.execute_run_action(mock_node) + + # Should have been cleared (set to None) + assert mock_node.action_label is None + + def test_calls_run_and_update_namespace( + self, mock_action_manager, mock_node, non_interactive_mode + ): + """Test that execute_run_action calls _run_and_update_namespace.""" + + def test_func(node): + return {"computed": True} + + action = Action(test_func, mock_action_manager) + result = action.execute_run_action(mock_node) + + # Namespace should be updated + assert mock_node.namespace == {"computed": True} + assert result == {"computed": True} + + def test_returns_action_result( + self, + simple_action_function, + mock_action_manager, + mock_node, + non_interactive_mode, + ): + """Test that execute_run_action returns the action's result.""" + action = Action(simple_action_function, mock_action_manager) + result = action.execute_run_action(mock_node) + + assert result == {"result": "success", "value": 42} + + def test_does_not_inject_variables_in_non_interactive_mode( + self, + simple_action_function, + mock_action_manager, + mock_node, + non_interactive_mode, + ): + """Test that variable injection is skipped in non-interactive mode.""" + # In non-interactive mode, is_interactive() returns False + # so no frame manipulation should occur + + action = Action(simple_action_function, mock_action_manager) + with patch( + "qualibrate.runnables.run_action.action.get_frame_to_update_from_action" + ) as mock_get_frame: + result = action.execute_run_action(mock_node) + + # get_frame_to_update_from_action should NOT be called + mock_get_frame.assert_not_called() + + # But result should still be returned and namespace updated + assert result == {"result": "success", "value": 42} + assert mock_node.namespace == {"result": "success", "value": 42} + + def test_handles_action_with_no_return( + self, + action_with_no_return, + mock_action_manager, + mock_node, + non_interactive_mode, + ): + """Test action that returns None.""" + action = Action(action_with_no_return, mock_action_manager) + result = action.execute_run_action(mock_node) + + assert result is None + # Namespace should not be updated (no dict returned) + assert mock_node.namespace == {} + + def test_handles_action_with_non_dict_return( + self, + action_with_non_dict_return, + mock_action_manager, + mock_node, + non_interactive_mode, + ): + """Test action that returns a non-dict value.""" + action = Action(action_with_non_dict_return, mock_action_manager) + result = action.execute_run_action(mock_node) + + assert result == "just a string" + # Namespace should not be updated (not a dict) + assert mock_node.namespace == {} + + def test_current_action_not_cleared_on_exception( + self, + action_that_raises, + mock_action_manager, + mock_node, + non_interactive_mode, + ): + """Test that current_action remains set if action raises. + + Note: The current implementation does NOT have a try-finally block, + so current_action will not be cleared when an exception occurs. + """ + action = Action(action_that_raises, mock_action_manager) + + with pytest.raises(ValueError, match="Test error from action"): + action.execute_run_action(mock_node) + + # current_action is NOT cleared on exception + assert mock_action_manager.current_action is action + + def test_exception_propagates_to_caller( + self, + action_that_raises, + mock_action_manager, + mock_node, + non_interactive_mode, + ): + """Test that exceptions from actions propagate correctly.""" + action = Action(action_that_raises, mock_action_manager) + + with pytest.raises(ValueError) as exc_info: + action.execute_run_action(mock_node) + + assert "Test error from action" in str(exc_info.value) + + +class TestActionIntegration: + """Integration tests for Action with ActionManager.""" + + def test_multiple_actions_share_namespace( + self, mock_action_manager, mock_node, non_interactive_mode + ): + """Test that multiple actions can share the namespace.""" + + def action1(node): + return {"data": [1, 2, 3], "length": 3} + + def action2(node): + # Access data from previous action + data = node.namespace["data"] + return {"sum": sum(data)} + + action_obj1 = Action(action1, mock_action_manager) + action_obj2 = Action(action2, mock_action_manager) + + action_obj1.execute_run_action(mock_node) + action_obj2.execute_run_action(mock_node) + + assert mock_node.namespace == { + "data": [1, 2, 3], + "length": 3, + "sum": 6, + } + + def test_action_tracking_across_multiple_executions( + self, mock_action_manager, mock_node, non_interactive_mode + ): + """Test that current_action tracking works across multiple actions.""" + + def action1(node): + return {"first": True} + + def action2(node): + return {"second": True} + + action_obj1 = Action(action1, mock_action_manager) + action_obj2 = Action(action2, mock_action_manager) + + # Execute first action + action_obj1.execute_run_action(mock_node) + assert mock_action_manager.current_action is action_obj1 + + # Execute second action + action_obj2.execute_run_action(mock_node) + assert mock_action_manager.current_action is action_obj2 diff --git a/tests/unit/test_runnables/test_run_action/test_action_manager.py b/tests/unit/test_runnables/test_run_action/test_action_manager.py new file mode 100644 index 0000000..558cbb3 --- /dev/null +++ b/tests/unit/test_runnables/test_run_action/test_action_manager.py @@ -0,0 +1,363 @@ +""" +Tests for ActionManager class (non-interactive mode only). +""" + +from unittest.mock import Mock, patch + +import pytest + +from qualibrate.runnables.run_action.action import Action +from qualibrate.runnables.run_action.action_manager import ActionManager + + +class TestActionManagerInit: + """Tests for ActionManager initialization.""" + + def test_init_creates_empty_actions_dict(self, action_manager): + """Test that __init__ creates an empty actions dict.""" + assert action_manager.actions == {} + assert isinstance(action_manager.actions, dict) + + def test_init_sets_current_action_to_none(self, action_manager): + """Test that current_action is initially None.""" + assert action_manager.current_action is None + + def test_init_sets_skip_actions_to_false(self, action_manager): + """Test that skip_actions defaults to False.""" + assert action_manager.skip_actions is False + assert action_manager._skip_actions is False + + def test_init_captures_predefined_names(self): + """Test that predefined_names are captured from frame.""" + test_names = {"x", "y", "print", "len"} + + with ( + patch( + "qualibrate.runnables.run_action.action_manager.inspect.stack" + ), + patch( + "qualibrate.runnables.run_action.action_manager" + ".get_frame_for_keeping_names_from_manager" + ), + patch( + "qualibrate.runnables.run_action.action_manager" + ".get_defined_in_frame_names", + return_value=test_names, + ), + ): + manager = ActionManager() + + assert manager.predefined_names == test_names + assert "x" in manager.predefined_names + assert "print" in manager.predefined_names + + +class TestSkipActionsProperty: + """Tests for the skip_actions property.""" + + def test_set_skip_actions_true(self, action_manager): + """Test setting skip_actions to True (skip all).""" + action_manager.skip_actions = True + + assert action_manager.skip_actions is True + assert action_manager._skip_actions is True + assert action_manager._skip_actions_names == set() + + def test_set_skip_actions_false(self, action_manager): + """Test setting skip_actions to False (run all).""" + action_manager.skip_actions = False + + assert action_manager.skip_actions is False + assert action_manager._skip_actions is False + assert action_manager._skip_actions_names == set() + + def test_set_skip_actions_with_list(self, action_manager): + """Test setting skip_actions to a list of action names.""" + action_manager.skip_actions = ["action1", "action2"] + + assert action_manager.skip_actions is True # Flag is set + assert action_manager._skip_actions_names == {"action1", "action2"} + + def test_set_skip_actions_with_empty_list(self, action_manager): + """Test setting skip_actions to empty list (skip none).""" + action_manager.skip_actions = [] + + assert action_manager.skip_actions is True # Flag is set + assert action_manager._skip_actions_names == set() + + def test_set_skip_actions_with_single_item_list(self, action_manager): + """Test setting skip_actions to single-item list.""" + action_manager.skip_actions = ["single_action"] + + assert action_manager._skip_actions_names == {"single_action"} + + def test_set_skip_actions_invalid_type_raises(self, action_manager): + """Test that invalid types raise TypeError.""" + with pytest.raises(TypeError, match="Invalid value.*for skip_actions"): + action_manager.skip_actions = 123 # Invalid type + + def test_set_skip_actions_list_with_non_strings_raises( + self, action_manager + ): + """Test that list with non-string items raises TypeError.""" + with pytest.raises(TypeError, match="Invalid value.*for skip_actions"): + action_manager.skip_actions = ["action1", 123, "action2"] + + def test_toggle_skip_actions_between_modes(self, action_manager): + """Test toggling between bool and list modes.""" + # Start with list mode + action_manager.skip_actions = ["action1"] + assert action_manager._skip_actions_names == {"action1"} + + # Switch to bool mode (clears names) + action_manager.skip_actions = True + assert action_manager._skip_actions_names == set() + + # Switch back to list mode + action_manager.skip_actions = ["action2", "action3"] + assert action_manager._skip_actions_names == {"action2", "action3"} + + # Disable skipping + action_manager.skip_actions = False + assert action_manager._skip_actions is False + assert action_manager._skip_actions_names == set() + + +class TestRunAction: + """Tests for the run_action method.""" + + @pytest.fixture + def mock_action(self): + """Provide a mock Action.""" + action = Mock(spec=Action) + action.execute_run_action = Mock(return_value={"result": "success"}) + return action + + def test_run_action_executes_found_action( + self, action_manager, mock_node, mock_action + ): + """Test that run_action executes a registered action.""" + action_manager.actions["test_action"] = mock_action + + result = action_manager.run_action("test_action", mock_node) + + mock_action.execute_run_action.assert_called_once_with(mock_node) + assert result == {"result": "success"} + + def test_run_action_not_found_returns_none(self, action_manager, mock_node): + """Test that run_action returns None for non-existent action.""" + result = action_manager.run_action("nonexistent", mock_node) + + assert result is None + + def test_run_action_not_found_logs_warning( + self, action_manager, mock_node, caplog + ): + """Test that missing action logs a warning.""" + action_manager.run_action("nonexistent", mock_node) + + assert "Can't run action nonexistent" in caplog.text + + def test_run_action_respects_skip_all( + self, action_manager, mock_node, mock_action + ): + """Test that skip_actions=True skips all actions.""" + action_manager.actions["test_action"] = mock_action + action_manager.skip_actions = True + + result = action_manager.run_action("test_action", mock_node) + + mock_action.execute_run_action.assert_not_called() + assert result is None + + def test_run_action_respects_skip_specific( + self, action_manager, mock_node, mock_action + ): + """Test that skip_actions with list skips specific actions.""" + action_manager.actions["action1"] = mock_action + action_manager.actions["action2"] = Mock(spec=Action) + action_manager.skip_actions = ["action1"] + + # action1 should be skipped + result1 = action_manager.run_action("action1", mock_node) + assert result1 is None + mock_action.execute_run_action.assert_not_called() + + # action2 should run + action_manager.run_action("action2", mock_node) + action_manager.actions[ + "action2" + ].execute_run_action.assert_called_once() + + def test_run_action_with_args_kwargs( + self, action_manager, mock_node, mock_action + ): + """Test that run_action passes args and kwargs to action.""" + action_manager.actions["test_action"] = mock_action + + action_manager.run_action("test_action", mock_node, "arg1", key="value") + + mock_action.execute_run_action.assert_called_once_with( + mock_node, "arg1", key="value" + ) + + def test_run_action_skip_logs_info( + self, action_manager, mock_node, mock_action, caplog + ): + """Test that skipped action logs info message.""" + action_manager.actions["test_action"] = mock_action + action_manager.skip_actions = True + + action_manager.run_action("test_action", mock_node) + + assert "Skipping action test_action" in caplog.text + + +class TestRegisterAction: + """Tests for the register_action decorator.""" + + def test_register_without_parentheses_registers_action( + self, action_manager, mock_node, non_interactive_mode + ): + """Test @decorator syntax registers the action.""" + mock_node._action_manager = action_manager + + @action_manager.register_action(mock_node) + def test_action(node): + return {"value": 42} + + assert "test_action" in action_manager.actions + assert isinstance(action_manager.actions["test_action"], Action) + + def test_register_without_parentheses_executes_immediately( + self, action_manager, mock_node, non_interactive_mode + ): + """Test @decorator syntax executes action immediately.""" + mock_node._action_manager = action_manager + execution_count = {"count": 0} + + @action_manager.register_action(mock_node) + def test_action(node): + execution_count["count"] += 1 + return {"value": 42} + + # Action should have executed once during registration + assert execution_count["count"] == 1 + + def test_register_with_skip_if_false_executes( + self, action_manager, mock_node, non_interactive_mode + ): + """Test @decorator(skip_if=False) executes immediately.""" + mock_node._action_manager = action_manager + execution_count = {"count": 0} + + @action_manager.register_action(mock_node, skip_if=False) + def test_action(node): + execution_count["count"] += 1 + return {"value": 42} + + assert execution_count["count"] == 1 + + def test_register_with_skip_if_true_does_not_execute( + self, action_manager, mock_node, non_interactive_mode + ): + """Test @decorator(skip_if=True) does NOT execute immediately.""" + mock_node._action_manager = action_manager + execution_count = {"count": 0} + + @action_manager.register_action(mock_node, skip_if=True) + def test_action(node): + execution_count["count"] += 1 + return {"value": 42} + + # Action should be registered but NOT executed + assert "test_action" in action_manager.actions + assert execution_count["count"] == 0 + + def test_register_returns_callable_wrapper( + self, action_manager, mock_node, non_interactive_mode + ): + """Test that register_action returns a callable wrapper.""" + mock_node._action_manager = action_manager + + @action_manager.register_action(mock_node) + def test_action(node): + return {"value": 42} + + assert callable(test_action) + + def test_wrapper_can_be_called_again( + self, action_manager, mock_node, non_interactive_mode + ): + """Test that the wrapper function can be called multiple times.""" + mock_node._action_manager = action_manager + execution_count = {"count": 0} + + @action_manager.register_action(mock_node) + def test_action(node): + execution_count["count"] += 1 + return {"value": execution_count["count"]} + + # Already executed once during registration + assert execution_count["count"] == 1 + + # Call again manually + result = test_action() + assert execution_count["count"] == 2 + assert result == {"value": 2} + + # Call one more time + result = test_action() + assert execution_count["count"] == 3 + assert result == {"value": 3} + + def test_wrapper_preserves_function_metadata( + self, action_manager, mock_node, non_interactive_mode + ): + """Test that wrapper preserves original function metadata.""" + mock_node._action_manager = action_manager + + @action_manager.register_action(mock_node, skip_if=True) + def test_action(node): + """Test docstring.""" + return {"value": 42} + + assert test_action.__name__ == "test_action" + assert test_action.__doc__ == "Test docstring." + + def test_multiple_actions_registered_sequentially( + self, action_manager, mock_node, non_interactive_mode + ): + """Test registering multiple actions in sequence.""" + mock_node._action_manager = action_manager + + @action_manager.register_action(mock_node, skip_if=True) + def action1(node): + return {"a": 1} + + @action_manager.register_action(mock_node, skip_if=True) + def action2(node): + return {"b": 2} + + @action_manager.register_action(mock_node, skip_if=True) + def action3(node): + return {"c": 3} + + assert len(action_manager.actions) == 3 + assert "action1" in action_manager.actions + assert "action2" in action_manager.actions + assert "action3" in action_manager.actions + + def test_register_action_with_node_namespace_update( + self, action_manager, mock_node, non_interactive_mode + ): + """Test that action updates node.namespace when returning dict.""" + mock_node._action_manager = action_manager + + @action_manager.register_action(mock_node) + def test_action(node): + return {"computed": True, "value": 100} + + # Namespace should be updated (action executed during registration) + assert mock_node.namespace.get("computed") is True + assert mock_node.namespace.get("value") == 100