Back to home page

EIC code displayed by LXR

 
 

    


File indexing completed on 2026-08-12 08:24:57

0001 """ExecutionEngine for DAG-based workflow execution.
0002 ExecutionEngines are the smallest executable units in a workflow. They encapsulate
0003 the logic to execute a specific job type (bash command, Python function,
0004 Docker container, etc). Each execution engine runs within a JobContext and can
0005 exchange data via XCom.
0006 
0007 Inspired by Apache Airflow's operator model, execution engines provide:
0008 - Job execution logic
0009 - Parameter handling and templating
0010 - Context-aware execution (logs, XCom, artifacts)
0011 - Failure handling and retries
0012 
0013 Supported execution engines:
0014     BaseExecutionEngine: Abstract base for all execution engines
0015     BashExecutionEngine: Execute shell commands
0016     PythonExecutionEngine: Execute Python functions
0017     ContainerExecutionEngine: Execute Docker containers
0018 
0019 Context hierarchy:
0020     JobContext: Execution context for a single job
0021     StageContext: Parameters shared across all jobs in a stage
0022     BranchContext: Parameters shared across all stages in a branch
0023 
0024 Project: AID2E v0.0.0 - AI assisted Detector Design for EIC
0025 Homepage: https://aid2e.github.io/aid2e-framework
0026 Repository: https://github.com/aid2e/AID2E-framework.git
0027 """
0028 
0029 from abc import ABC, abstractmethod
0030 from ast import literal_eval
0031 from typing import Any, Callable, Dict, List, Optional
0032 from dataclasses import dataclass, field
0033 from functools import reduce
0034 import subprocess
0035 import json
0036 import os
0037 import re
0038 from pathlib import Path
0039 
0040 from aid2e.utilities.configurations.problem_config import (
0041     ProblemConfiguration,
0042 )
0043 from aid2e.utilities.configurations.experimental_stack_config import (
0044     StackLayerConfig,
0045 )
0046 from aid2e.utilities.configurations.stack_registry import StackRegistry
0047 
0048 from .experimental_stack import ExperimentStack
0049 
0050 
0051 @dataclass
0052 class WorkflowSharedContext:
0053     """Context shared across all jobs of one workflow execution.
0054 
0055     Attributes:
0056         workflow_id: Unique workflow identifier.
0057         parameters: Parameters available to all branches, stages, and
0058                     jobs in this workflow.
0059     """
0060     workflow_id: str
0061     parameters: Dict[str, Any] = field(default_factory=dict)
0062 
0063 
0064 @dataclass
0065 class BranchContext:
0066     """Context for parameters shared across all stages in a branch.
0067     
0068     Attributes:
0069         branch_id: Unique branch identifier.
0070         parameters: Parameters available to all stages in this branch.
0071     """
0072     branch_id: str
0073     parameters: Dict[str, Any] = field(default_factory=dict)
0074 
0075 
0076 @dataclass
0077 class StageContext:
0078     """Context for parameters shared across all jobs in a stage.
0079 
0080     Attributes:
0081         stage_id: Unique stage identifier.
0082         parameters: Parameters available to all jobs in this stage.
0083         branch_context: Parent branch context (optional).
0084     """
0085     stage_id: str
0086     parameters: Dict[str, Any] = field(default_factory=dict)
0087     branch_context: Optional[BranchContext] = None
0088 
0089 
0090 @dataclass
0091 class JobContext:
0092     """Execution context for a single job (XCom-like data passing).
0093     
0094     Holds job metadata, input/output data, logs, and artifacts.
0095     Enables data flow between jobs in a workflow.
0096     
0097     Attributes:
0098         task_id: Key encoding stage, job ID. Used in XCom,
0099                  formatted as {stage_id}:{job_id}
0100         job_id: Unique job identifier.
0101         stage_id: Parent stage identifier.
0102         workflow_id: Root workflow identifier.
0103         design_point: Input design point (optimizer output).
0104         xcom: Dict of data from upstream jobs (job_id:key → value).
0105         artifacts: Dict of output artifact paths produced by this job.
0106         logs: Execution logs (stdout/stderr).
0107         execution_dir: Working directory for job execution.
0108         stage_context: Parent stage context (optional).
0109         problem_config: Problem configuration for accessing stack-
0110                         dependent design space
0111         workflow_context: Shared workflow context (optional).
0112     """
0113     task_id: str
0114     job_id: str
0115     stage_id: str
0116     workflow_id: str
0117     design_point: Dict[str, Any] = field(default_factory=dict)
0118     xcom: Dict[str, Any] = field(default_factory=dict)
0119     artifacts: Dict[str, str] = field(default_factory=dict)
0120     logs: List[str] = field(default_factory=list)
0121     execution_dir: Optional[str] = None
0122     output_dir: Optional[str] = None
0123     stage_context: Optional[StageContext] = None
0124     problem_config: Optional[ProblemConfiguration] = None
0125     workflow_context: Optional[WorkflowSharedContext] = None
0126 
0127     def xcom_key(self, key: str, task_id: str) -> str:
0128         """Get xcom key for a given job, stage
0129 
0130         Args:
0131             key: XCom key (e.g. 'return_value', 'metrics').
0132             task_id: Unique ID of job + stage (e.g. 'sim_stage:sim_job')
0133 
0134         Returns:
0135             Key formatted as task_id:key
0136         """
0137         return f"{task_id}:{key}"
0138 
0139     def xcom_push(self, key: str, value: Any) -> None:
0140         """Push data to XCom for downstream jobs.
0141 
0142         Data stored in a dictionary with the format:
0143 
0144             {'task_id:key': data}
0145 
0146         Args:
0147             key: XCom data key (e.g., 'return_value', 'metrics').
0148             value: Data to push (any serializable type).
0149 
0150         Example:
0151             >>> context.xcom_push('objectives', {'f1': 0.5, 'f2': 0.3})
0152         """
0153         xcom_key = self.xcom_key(key, self.task_id)
0154         self.xcom[xcom_key] = value
0155 
0156     def xcom_pull(self, task_id: str, key: str = 'return_value') -> Any:
0157         """Pull data from upstream job's XCom.
0158 
0159         Args:
0160             task_id: Upstream job key
0161             key: XCom data key (optional, default is 'return_value').
0162 
0163         Returns:
0164             Data pushed by upstream job, or None if not found.
0165 
0166         Example:
0167             >>> params = context.xcom_pull('prepare_params', key='params')
0168         """
0169         xcom_key = self.xcom_key(key, task_id)
0170         return self.xcom.get(xcom_key)
0171 
0172     def add_log(self, message: str) -> None:
0173         """Add a log message.
0174         
0175         Args:
0176             message: Log message.
0177         """
0178         self.logs.append(message)
0179     
0180     def save_artifact(self, artifact_key: str, artifact_path: str) -> None:
0181         """Register an output artifact.
0182         
0183         Args:
0184             artifact_key: Logical name for artifact (e.g., 'objectives').
0185             artifact_path: File path to artifact.
0186             
0187         Example:
0188             >>> context.save_artifact('objectives', '/work/objectives.json')
0189         """
0190         self.artifacts[artifact_key] = artifact_path
0191 
0192 
0193 class Template:
0194     """class for common template substitutions
0195 
0196     Supports:
0197         - {{design_point.key}} → Value for design parameter with name `key`
0198         - {{job_id}} → Name of current job
0199         - {{stage_id}} → Name of current stage
0200         - {{branch_id}} → Name of current branch
0201         - {{workflow_id}} → Name of workflow
0202         - {{execution_dir}} → Current working directory
0203         - {{output_dir}} → Current output directory
0204         - {{geometry_dir}} → Geometry directory to use
0205         - {{artifacts[key]}} → Artifact path ID'd by key
0206         - {{xcom[key]}} → Scalar XCom data ID'd by key
0207         - {{xcom[key](acc)}} → Non-scalar XCom data ID'd by key,
0208                                accessed with acc
0209         - {{inputs[key](acc)}} → Stack layer input acc, ID'd by key
0210         - {{outputs[key](acc}} → Stack layer output acc, ID'd by key
0211         - {{arguments[key](acc}} → Stack layer argument acc, ID'd by key
0212 
0213     Attributes:
0214         _substitutions: Dictionary of template variables onto lambdas
0215                         to replace them. Format is {'pattern': 'rule'}.
0216     """
0217     _substitutions = {
0218         "{{design_point.key}}":
0219             (lambda text, context: reduce(lambda result, key: result.replace(f"{{{{design_point.{key[0]}}}}}", str(key[1])), context.design_point.items(), text)),
0220         "{{job_id}}":
0221             (lambda text, context: text.replace("{{job_id}}", str(context.job_id))),
0222         "{{stage_id}}":
0223             (lambda text, context: text.replace("{{stage_id}}", str(context.stage_id))),
0224         "{{branch_id}}":
0225             (lambda text, context: text.replace("{{branch_id}}", str(context.stage_context.branch_context.branch_id))
0226              if context.stage_context is not None and context.stage_context.branch_context is not None
0227              else text.replace("{{branch_id}}", "NotAvailable")),
0228         "{{workflow_id}}":
0229             (lambda text, context: text.replace("{{workflow_id}}", str(context.workflow_id))),
0230         "{{execution_dir}}":
0231             (lambda text, context: text.replace("{{execution_dir}}", str(context.execution_dir))),
0232         "{{output_dir}}":
0233             (lambda text, context: text.replace("{{output_dir}}", str(context.output_dir))),
0234         "{{geometry_dir}}":
0235             (lambda text, context: text.replace("{{geometry_dir}}", str(context.workflow_context.parameters["prepared_geometry_dir"]))
0236             if context.workflow_context is not None and "prepared_geometry_dir" in context.workflow_context.parameters
0237             else text.replace("{{geometry_dir}}", "NotAvailable")),
0238         "{{artifacts[key]}}":
0239             (lambda text, context:
0240                 re.sub(r"{{artifacts\[(.*?)\]}}", lambda match: str(context.artifacts[match.group(1)]), text)),
0241         "{{xcom[key]}}":
0242             (lambda text, context:
0243                 re.sub(r"{{xcom\[(.*?)\]}}", lambda match: str(context.xcom[match.group(1)]), text)),
0244         "{{xcom[key](acc)}}":
0245             (lambda text, context: re.sub(r"{{xcom\[(.*?)\]\((.*?)\)}}", lambda match: str(context.xcom[match.group(1)][literal_eval(match.group(2))]), text)),
0246         "{{inputs[key](acc)}}":
0247             (lambda text, context: re.sub(r"{{inputs\[(.*?)\]\((.*?)\)}}", lambda match: str(context.xcom[match.group(1) + ':inputs'][literal_eval(match.group(2))]), text)),
0248         "{{outputs[key](acc)}}":
0249             (lambda text, context: re.sub(r"{{outputs\[(.*?)\]\((.*?)\)}}", lambda match: str(context.xcom[match.group(1) + ':outputs'][literal_eval(match.group(2))]), text)),
0250         "{{arguments[key](acc)}}":
0251             (lambda text, context: re.sub(r"{{arguments\[(.*?)\]\((.*?)\)}}", lambda match: str(context.xcom[match.group(1) + ':arguments'][literal_eval(match.group(2))]), text)),
0252     }
0253 
0254     @classmethod
0255     def substitute(cls, text: str, context: JobContext) -> str:
0256         """Apply template substitutions
0257 
0258         Args:
0259             text: The text to apply substitution to
0260             context: JobContext holding job, stage, branch, and workflow info
0261         """
0262         result = text
0263         for template, substitution in cls._substitutions.items():
0264             result = substitution(result, context)
0265         return result
0266 
0267 
0268 class BaseExecutionEngine(ABC):
0269     """Base class for all execution engines.
0270     
0271     ExecutionEngines are reusable task implementations that can be combined
0272     in workflows. Each execution engine encapsulates the logic to execute
0273     a specific type of work (shell command, Python function, container, etc).
0274     
0275     Attributes:
0276         engine_id: Unique identifier.
0277         params: Task parameters (executor-dependent).
0278     """
0279     _template = Template
0280 
0281     def __init__(self, engine_id: str, **kwargs):
0282         """Initialize execution engine.
0283         
0284         Args:
0285             engine_id: Unique identifier for this task
0286             **kwargs: Execution engine-specific parameters.
0287         """
0288         self.engine_id = engine_id
0289         self.params = kwargs
0290 
0291     @abstractmethod
0292     def execute(self, context: JobContext) -> Any:
0293         """Execute the engine.
0294         
0295         Must be implemented by subclasses. Execution engines should:
0296         1. Use context.xcom_pull() to get inputs from upstream tasks
0297         2. Perform the actual work
0298         3. Use context.xcom_push() to return results
0299         4. Use context.add_log() for logging
0300         5. Use context.save_artifact() to register outputs
0301         
0302         Args:
0303             context: Operation execution context (XCom, logs, artifacts).
0304             
0305         Returns:
0306             Result of execution (any serializable type).
0307         """
0308         raise NotImplementedError
0309     
0310     def __repr__(self) -> str:
0311         """String representation of execution engine."""
0312         return f"{self.__class__.__name__}(engine_id='{self.engine_id}')"
0313 
0314 
0315 class BashExecutionEngine(BaseExecutionEngine):
0316     """Execute a bash shell command.
0317 
0318     Executes arbitrary shell commands, capturing stdout/stderr.
0319     Supports template variable substitution in bash_command and env.
0320 
0321     Attributes:
0322         bash_command: Bash command to execute.
0323         env: Environment variables (optional).
0324 
0325     Example:
0326         >>> engine = BashExecutionEngine(
0327         ...     engine_id='run_sim',
0328         ...     bash_command='python scripts/simulate.py --input {input_file}',
0329         ...     env={'PYTHONUNBUFFERED': '1'}
0330         ... )
0331         >>> result = engine.execute(context)
0332     """
0333 
0334     def __init__(self, engine_id: str, bash_command: str, env: Optional[Dict[str, str]] = None, **kwargs):
0335         """Initialize BashExecutionEngine.
0336 
0337         Args:
0338             engine_id: Task identifier.
0339             bash_command: Command to execute (supports template variables).
0340             env: Environment variables.
0341             **kwargs: Additional parameters.
0342         """
0343         super().__init__(engine_id, **kwargs)
0344         self.bash_command = bash_command
0345         self.env = env or {}
0346 
0347     def execute(self, context: JobContext) -> Dict[str, Any]:
0348         """Execute bash command.
0349 
0350         Args:
0351             context: Task context.
0352 
0353         Returns:
0354             Dict with 'stdout', 'stderr', 'returncode'.
0355 
0356         Raises:
0357             RuntimeError: If command fails (returncode != 0).
0358         """
0359         try:
0360             # Template substitution (simple string formatting)
0361             command = self._template.substitute(self.bash_command, context)
0362 
0363             context.add_log(f"Executing bash command: {command}")
0364 
0365             # Execute command
0366             result = subprocess.run(
0367                 command,
0368                 shell=True,
0369                 capture_output=True,
0370                 text=True,
0371                 env={**os.environ, **self.env} if self.env else None,
0372                 cwd=context.execution_dir
0373             )
0374 
0375             # Log output
0376             if result.stdout:
0377                 context.add_log(f"STDOUT:\n{result.stdout}")
0378             if result.stderr:
0379                 context.add_log(f"STDERR:\n{result.stderr}")
0380 
0381             # Push results to XCom
0382             output = {
0383                 'stdout': result.stdout,
0384                 'stderr': result.stderr,
0385                 'returncode': result.returncode
0386             }
0387             context.xcom_push('return_value', output)
0388 
0389             if result.returncode != 0:
0390                 raise RuntimeError(f"Command failed with code {result.returncode}")
0391 
0392             return output
0393 
0394         except Exception as e:
0395             context.add_log(f"ERROR: {str(e)}")
0396             raise
0397 
0398 
0399 class PythonExecutionEngine(BaseExecutionEngine):
0400     """Execute a Python callable (function).
0401 
0402     Executes a Python function with optional arguments.
0403     The function receives the JobContext as first argument.
0404 
0405     Attributes:
0406         python_callable: Function to execute.
0407         op_args: Positional arguments to function.
0408         op_kwargs: Keyword arguments to function.
0409 
0410     Example:
0411         >>> def compute_metrics(context, threshold=0.5):
0412         ...     data = context.xcom_pull('upstream_task', 'data')
0413         ...     result = apply_threshold(data, threshold)
0414         ...     context.xcom_push('metrics', result)
0415         ...     return result
0416         >>> 
0417         >>> engine = PythonExecutionEngine(
0418         ...     engine_id='compute',
0419         ...     python_callable=compute_metrics,
0420         ...     op_kwargs={'threshold': 0.7}
0421         ... )
0422     """
0423 
0424     def __init__(
0425         self,
0426         engine_id: str,
0427         python_callable: Callable,
0428         op_args: Optional[tuple] = None,
0429         op_kwargs: Optional[Dict[str, Any]] = None,
0430         **kwargs
0431     ):
0432         """Initialize PythonExecutionEngine.
0433 
0434         Args:
0435             job_id: Task identifier.
0436             python_callable: Function to execute.
0437             op_args: Positional arguments (after context).
0438             op_kwargs: Keyword arguments.
0439             **kwargs: Additional parameters.
0440         """
0441         super().__init__(engine_id, **kwargs)
0442         self.python_callable = python_callable
0443         self.op_args = op_args or ()
0444         self.op_kwargs = op_kwargs or {}
0445 
0446     def execute(self, context: JobContext) -> Any:
0447         """Execute Python function.
0448 
0449         Args:
0450             context: Task context (passed as first argument to callable).
0451 
0452         Returns:
0453             Function return value.
0454 
0455         Raises:
0456             Exception: Any exception raised by the function.
0457         """
0458         try:
0459             context.add_log(f"Executing Python callable: {self.python_callable.__name__}")
0460 
0461             # Call function with context as first argument
0462             result = self.python_callable(context, *self.op_args, **self.op_kwargs)
0463 
0464             context.add_log(f"Function returned: {result}")
0465 
0466             # Push return value to XCom
0467             context.xcom_push('return_value', result)
0468 
0469             return result
0470 
0471         except Exception as e:
0472             context.add_log(f"ERROR: {str(e)}")
0473             raise
0474 
0475 
0476 class ContainerExecutionEngine(BaseExecutionEngine):
0477     """Execute a Docker container.
0478 
0479     Runs a Docker image with specified parameters. Supports:
0480     - Environment variables
0481     - Volume mounts
0482     - Resource limits
0483     - Container command override
0484 
0485     Attributes:
0486         image: Docker image URI (e.g., 'python:3.10', 'ghcr.io/user/sim:latest').
0487         command: Container command override (optional).
0488         environment: Environment variables to pass into container.
0489         volumes: Volume mounts ({host_path: container_path}).
0490         resources: Resource constraints (memory, cpus, etc).
0491 
0492     Example:
0493         >>> engine = ContainerExecutionEngine(
0494         ...     engine_id='run_simulation',
0495         ...     image='physics-sim:1.0',
0496         ...     command=['/app/run_sim.sh'],
0497         ...     environment={
0498         ...         'INPUT_FILE': '/data/input.json',
0499         ...         'OUTPUT_DIR': '/output'
0500         ...     },
0501         ...     volumes={
0502         ...         '/host/data': '/data',
0503         ...         '/host/output': '/output'
0504         ...     },
0505         ...     resources={
0506         ...         'memory': '4g',
0507         ...         'cpus': '2'
0508         ...     }
0509         ... )
0510     """
0511 
0512     def __init__(
0513         self,
0514         engine_id: str,
0515         image: str,
0516         command: Optional[List[str]] = None,
0517         environment: Optional[Dict[str, str]] = None,
0518         volumes: Optional[Dict[str, str]] = None,
0519         resources: Optional[Dict[str, str]] = None,
0520         **kwargs
0521     ):
0522         """Initialize ContainerExecutionEngine.
0523 
0524         Args:
0525             engine_id: Task identifier.
0526             image: Docker image URI.
0527             command: Container command (overrides ENTRYPOINT).
0528             environment: Environment variables in container.
0529             volumes: Volume mounts (host_path: container_path).
0530             resources: Resource constraints.
0531             **kwargs: Additional parameters.
0532         """
0533         super().__init__(engine_id, **kwargs)
0534         self.image = image
0535         self.command = command
0536         self.environment = environment or {}
0537         self.volumes = volumes or {}
0538         self.resources = resources or {}
0539 
0540     def execute(self, context: JobContext) -> Dict[str, Any]:
0541         """Execute Docker container.
0542 
0543         Args:
0544             context: Task context.
0545 
0546         Returns:
0547             Dict with 'container_id', 'stdout', 'stderr', 'returncode'.
0548 
0549         Raises:
0550             RuntimeError: If docker run fails.
0551         """
0552         try:
0553             context.add_log(f"Running Docker container: {self.image}")
0554 
0555             # Build docker run command
0556             docker_cmd = self._build_docker_command(context)
0557 
0558             context.add_log(f"Docker command: {docker_cmd}")
0559 
0560             # Execute docker command
0561             result = subprocess.run(
0562                 docker_cmd,
0563                 shell=True,
0564                 capture_output=True,
0565                 text=True,
0566                 cwd=context.execution_dir
0567             )
0568 
0569             # Log output
0570             if result.stdout:
0571                 context.add_log(f"STDOUT:\n{result.stdout}")
0572             if result.stderr:
0573                 context.add_log(f"STDERR:\n{result.stderr}")
0574 
0575             # Extract container ID from output (if available)
0576             output = {
0577                 'stdout': result.stdout,
0578                 'stderr': result.stderr,
0579                 'returncode': result.returncode,
0580                 'image': self.image
0581             }
0582             context.xcom_push('return_value', output)
0583 
0584             if result.returncode != 0:
0585                 raise RuntimeError(f"Docker container failed with code {result.returncode}")
0586 
0587             return output
0588 
0589         except Exception as e:
0590             context.add_log(f"ERROR: {str(e)}")
0591             raise
0592 
0593     def _build_docker_command(self, context: JobContext) -> str:
0594         """Build docker run command.
0595         
0596         Args:
0597             context: Task context.
0598             
0599         Returns:
0600             Docker run command string.
0601         """
0602         cmd_parts = ['docker', 'run', '--rm']
0603         
0604         # Add environment variables
0605         for key, value in self.environment.items():
0606             # Template substitution for environment values
0607             value_resolved = self._template.substitute(value, context)
0608             cmd_parts.append(f'-e {key}={value_resolved}')
0609 
0610         # Add volume mounts
0611         for host_path, container_path in self.volumes.items():
0612             cmd_parts.append(f'-v {host_path}:{container_path}')
0613 
0614         # Add resource constraints
0615         if 'memory' in self.resources:
0616             cmd_parts.append(f'-m {self.resources["memory"]}')
0617         if 'cpus' in self.resources:
0618             cmd_parts.append(f'--cpus {self.resources["cpus"]}')
0619 
0620         # Add image
0621         cmd_parts.append(self.image)
0622 
0623         # Add command override
0624         if self.command:
0625             cmd_parts.extend(self.command)
0626 
0627         return ' '.join(cmd_parts)
0628 
0629 
0630 class StackExecutionEngine(BaseExecutionEngine):
0631     """Execute layers of an experimental software stack.
0632 
0633     Runs a sequence of layers of a generic experimental
0634     software stack.
0635 
0636     Example:
0637         >>> engine = StackExecutionEngine(
0638         ...     engine_id='run_simulation',
0639         ...     stack_type='EpicStack',
0640         ...     layers=[
0641         ...         StackLayerConfig(
0642         ...             name='sim',
0643         ...             inputs='in.hepmc3.tree.root',
0644         ...             outputs='out.edm4hep.root',
0645         ...         ],
0646         ...     ]
0647         ... )
0648     """
0649 
0650     def __init__(
0651         self,
0652         engine_id: str,
0653         stack_type: str,
0654         layers: List[StackLayerConfig],
0655         **kwargs
0656     ):
0657         """Initialize StackExecutionEngine
0658 
0659         Args:
0660             engine_id: Task identifier
0661             stack_type: Which type of stack to use (e.g. 'EpicStack')
0662             layers: List of layers to run
0663         """
0664         super().__init__(engine_id, **kwargs)
0665         self.layers = layers
0666         self.stack_type = stack_type
0667         self.stack_class = StackRegistry.get_experimental_stack(self.stack_type)
0668         if not self.stack_class:
0669             raise ValueError(f"Unknown stack type: {stack_type}")
0670 
0671     def execute(self, context: JobContext) -> Dict[str, Any]:
0672         """Execute experimental stack
0673 
0674         Args:
0675             context: Task context.
0676 
0677         Returns:
0678             Dict with 'stdout', 'stderr', 'returncode'
0679 
0680         Raises:
0681             RuntimeError: If execution fails
0682 
0683         Note:
0684             Layer inputs, outputs, and arguments are pushed
0685             to XCom for retrieval downstream.
0686         """
0687         stack = self.stack_class()
0688 
0689         # Do any preparations ahead of execution
0690         preparations = stack.prepare_for_execution(context = context)
0691 
0692         # Substitute templates in each layer's inputs/outputs/args and
0693         # push info to XCom for downstream tasks
0694         for layer in self.layers:
0695             self._apply_template_substitution(layer, context)
0696             context.xcom_push(f'{layer.name}:inputs', layer.inputs)
0697             context.xcom_push(f'{layer.name}:outputs', layer.outputs)
0698             context.xcom_push(f'{layer.name}:arguments', layer.arguments)
0699 
0700         # Build driver script and command to run it
0701         driver = f"{context.execution_dir}/{self.engine_id}_driver.sh"
0702         command = stack.make_driver_command(driver)
0703         stack.make_driver_script(
0704             script=driver,
0705             configs=self.layers,
0706             preparations=preparations,
0707             context=context,
0708         )
0709 
0710         # Append script and command to context
0711         context.add_log(f"Driver script: {driver}")
0712         context.add_log(f"Driver command: {command}")
0713 
0714         # Try running command
0715         try:
0716             result = subprocess.run(
0717                 command,
0718                 shell=True,
0719                 capture_output=True,
0720                 text=True,
0721             )
0722 
0723             # Log output
0724             if result.stdout:
0725                 context.add_log(f"STDOUT:\n{result.stdout}")
0726             if result.stderr:
0727                 context.add_log(f"STDERR:\n{result.stderr}")
0728 
0729             # Push any output to XCom
0730             output = {
0731                 'stdout': result.stdout,
0732                 'stderr': result.stderr,
0733                 'returncode': result.returncode,
0734             }
0735             context.xcom_push('return_value', output)
0736 
0737             if result.returncode != 0:
0738                 raise RuntimeError(f"{self.stack_type} execution failed with code {result.returncode}")
0739             return output
0740 
0741         # And throw generic exception if
0742         # something goes wrong
0743         except Exception as e:
0744             context.add_log(f"ERROR: {str(e)}")
0745             raise
0746 
0747     def _apply_template_substitution(self, layer: StackLayerConfig, context: JobContext) -> None:
0748         """
0749         Apply template substitutions to a layer config
0750 
0751         Args:
0752             layer: The layer config to apply substitutions to
0753             context: Context for the current job
0754         """
0755         resolved_inputs = list()
0756         for layer_input in layer.inputs:
0757             layer_input = self._template.substitute(layer_input, context)
0758             resolved_inputs.append(layer_input)
0759         layer.inputs = resolved_inputs
0760 
0761         resolved_outputs = list()
0762         for layer_output in layer.outputs:
0763             layer_output = self._template.substitute(layer_output, context)
0764             resolved_outputs.append(layer_output)
0765         layer.outputs = resolved_outputs
0766 
0767         if layer.arguments is not None:
0768             resolved_arguments = list()
0769             for layer_argument in layer.arguments:
0770                 layer_argument = self._template.substitute(layer_argument, context)
0771                 resolved_arguments.append(layer_argument)
0772             layer.arguments = resolved_arguments
0773 
0774 
0775 __all__ = [
0776     'BranchContext',
0777     'StageContext',
0778     'JobContext',
0779     'Template',
0780     'BaseExecutionEngine',
0781     'BashExecutionEngine',
0782     'PythonExecutionEngine',
0783     'ContainerExecutionEngine',
0784     'StackExecutionEngine'
0785 ]