use std::path::Path;
use thiserror::Error;
#[derive(Debug, Error)]
pub enum PythonExecutionError {
#[error("Python environment setup failed: {reason}")]
EnvironmentSetup { reason: String },
#[error("Task '{task_id}' not found in module '{module}'")]
TaskNotFound { task_id: String, module: String },
#[error("Task '{task_id}' raised an exception: {message}\n{traceback}")]
TaskException {
task_id: String,
message: String,
traceback: String,
},
#[error("Context serialization error for task '{task_id}': {reason}")]
SerializationError { task_id: String, reason: String },
#[error("Import error in task '{task_id}': {message}")]
ImportError { task_id: String, message: String },
#[error("Python runtime not available: {reason}")]
RuntimeUnavailable { reason: String },
}
#[derive(Debug, Clone)]
pub struct PythonTaskResult {
pub task_id: String,
pub output_json: String,
}
#[async_trait::async_trait]
pub trait PythonTaskExecutor: Send + Sync {
async fn execute_task(
&self,
workflow_dir: &Path,
vendor_dir: &Path,
entry_module: &str,
task_id: &str,
context_json: &str,
) -> Result<PythonTaskResult, PythonExecutionError>;
async fn discover_tasks(
&self,
workflow_dir: &Path,
vendor_dir: &Path,
entry_module: &str,
) -> Result<Vec<String>, PythonExecutionError>;
}
#[cfg(test)]
mod tests {
use super::*;
struct MockPythonExecutor {
task_ids: Vec<String>,
}
#[async_trait::async_trait]
impl PythonTaskExecutor for MockPythonExecutor {
async fn execute_task(
&self,
_workflow_dir: &Path,
_vendor_dir: &Path,
_entry_module: &str,
task_id: &str,
context_json: &str,
) -> Result<PythonTaskResult, PythonExecutionError> {
if !self.task_ids.contains(&task_id.to_string()) {
return Err(PythonExecutionError::TaskNotFound {
task_id: task_id.to_string(),
module: "mock".to_string(),
});
}
Ok(PythonTaskResult {
task_id: task_id.to_string(),
output_json: context_json.to_string(),
})
}
async fn discover_tasks(
&self,
_workflow_dir: &Path,
_vendor_dir: &Path,
_entry_module: &str,
) -> Result<Vec<String>, PythonExecutionError> {
Ok(self.task_ids.clone())
}
}
#[tokio::test]
async fn test_mock_executor_discover() {
let exec = MockPythonExecutor {
task_ids: vec!["extract".to_string(), "transform".to_string()],
};
let ids = exec
.discover_tasks(Path::new("/tmp"), Path::new("/tmp"), "workflow.tasks")
.await
.unwrap();
assert_eq!(ids, vec!["extract", "transform"]);
}
#[tokio::test]
async fn test_mock_executor_execute() {
let exec = MockPythonExecutor {
task_ids: vec!["extract".to_string()],
};
let result = exec
.execute_task(
Path::new("/tmp"),
Path::new("/tmp"),
"workflow.tasks",
"extract",
r#"{"key": "value"}"#,
)
.await
.unwrap();
assert_eq!(result.task_id, "extract");
assert_eq!(result.output_json, r#"{"key": "value"}"#);
}
#[tokio::test]
async fn test_mock_executor_task_not_found() {
let exec = MockPythonExecutor { task_ids: vec![] };
let err = exec
.execute_task(
Path::new("/tmp"),
Path::new("/tmp"),
"workflow.tasks",
"missing",
"{}",
)
.await
.unwrap_err();
assert!(matches!(err, PythonExecutionError::TaskNotFound { .. }));
}
#[test]
fn test_error_display() {
let err = PythonExecutionError::TaskException {
task_id: "my_task".to_string(),
message: "ZeroDivisionError: division by zero".to_string(),
traceback: " File \"tasks.py\", line 10".to_string(),
};
let msg = err.to_string();
assert!(msg.contains("my_task"));
assert!(msg.contains("ZeroDivisionError"));
}
}