Skip to main content

rpytest_daemon/
embedded.rs

1//! Embedded Python execution using PyO3.
2//!
3//! This module provides native Python execution by embedding the Python interpreter
4//! directly into the Rust daemon, eliminating subprocess overhead.
5//!
6//! Key features:
7//! - Direct pytest.main() calls without subprocess spawn
8//! - Streaming results via custom pytest plugin
9//! - Module cache cleanup between runs
10//! - Signal handler and event loop management
11//! - Python 3.8+ support via abi3 stable ABI
12
13use crate::error::{DaemonError, Result};
14use crate::executor::{ExecutorConfig, TestExecutor};
15use crate::models::{TestOutcome, TestResult};
16use async_trait::async_trait;
17use parking_lot::Mutex;
18use pyo3::prelude::*;
19use pyo3::types::{PyDict, PyList, PyModule};
20use std::path::PathBuf;
21use std::sync::atomic::{AtomicBool, Ordering};
22use std::sync::{Arc, OnceLock};
23use tracing::{debug, info, warn};
24
25/// Ensure PYTHONHOME is set before PyO3 initializes the interpreter.
26///
27/// Without PYTHONHOME, the embedded Python interpreter cannot find the standard
28/// library (e.g., `encodings` module), causing a fatal error:
29///   "Fatal Python error: Failed to import encodings module"
30///
31/// This function detects the correct prefix by running the system `python3`.
32fn ensure_pythonhome() {
33    if std::env::var("PYTHONHOME").is_ok() {
34        debug!("PYTHONHOME already set");
35        return;
36    }
37
38    // Try to detect from VIRTUAL_ENV first (resolve to base_prefix)
39    if let Ok(venv) = std::env::var("VIRTUAL_ENV") {
40        // In a virtualenv, we need the base prefix (the actual Python installation),
41        // not the virtualenv prefix itself.
42        let python = std::path::Path::new(&venv).join("bin").join("python3");
43        if python.exists() {
44            if let Some(base_prefix) = query_python_base_prefix(&python) {
45                info!(
46                    "Auto-detected PYTHONHOME from VIRTUAL_ENV's base_prefix: {}",
47                    base_prefix
48                );
49                std::env::set_var("PYTHONHOME", &base_prefix);
50                return;
51            }
52        }
53    }
54
55    // Fall back to system python3
56    if let Some(base_prefix) = query_python_base_prefix(std::path::Path::new("python3")) {
57        info!("Auto-detected PYTHONHOME from python3: {}", base_prefix);
58        std::env::set_var("PYTHONHOME", &base_prefix);
59        return;
60    }
61
62    warn!("Could not auto-detect PYTHONHOME; embedded Python may fail to initialize");
63}
64
65/// Run `python3 -c "import sys; print(sys.base_prefix)"` and return the output.
66fn query_python_base_prefix(python: &std::path::Path) -> Option<String> {
67    match std::process::Command::new(python)
68        .args(["-c", "import sys; print(sys.base_prefix)"])
69        .stdout(std::process::Stdio::piped())
70        .stderr(std::process::Stdio::null())
71        .output()
72    {
73        Ok(output) if output.status.success() => {
74            let prefix = String::from_utf8_lossy(&output.stdout).trim().to_string();
75            if !prefix.is_empty() {
76                Some(prefix)
77            } else {
78                None
79            }
80        }
81        Ok(_) => {
82            debug!("python3 exited with non-zero status while querying base_prefix");
83            None
84        }
85        Err(e) => {
86            debug!("Failed to run python3 for base_prefix detection: {}", e);
87            None
88        }
89    }
90}
91
92/// Global cache for compiled plugin class - avoids recompiling Python on every run
93static CACHED_PLUGIN_CLASS: OnceLock<Py<PyAny>> = OnceLock::new();
94
95/// Track if this is the first test run (skip cleanup on first run)
96static FIRST_RUN: AtomicBool = AtomicBool::new(true);
97
98/// Python plugin code for collecting test results.
99/// This plugin hooks into pytest's reporting system to stream results to Rust.
100/// OPTIMIZED: Batches results locally and sends them all at once in pytest_sessionfinish
101const COLLECTOR_PLUGIN: &str = r#"
102import pytest
103
104class RpytestCollectorPlugin:
105    """Pytest plugin that collects results and streams them to Rust."""
106
107    def __init__(self, collector):
108        self.collector = collector
109        self._collected_count = 0
110        self._passed = 0
111        self._failed = 0
112        self._skipped = 0
113        self._errors = 0
114        # OPTIMIZATION: Batch results locally instead of calling Rust for each test
115        self._results_batch = []
116
117    def pytest_runtest_logreport(self, report):
118        """Called for each test phase (setup, call, teardown)."""
119        # Only record the 'call' phase for test outcomes
120        # setup/teardown phases are tracked separately
121        if report.when == 'call':
122            # Batch locally instead of calling Rust per-result
123            self._results_batch.append((
124                report.nodeid,
125                report.outcome,
126                report.duration,
127                getattr(report, 'longreprtext', None)
128            ))
129
130            # Track summary counts
131            if report.outcome == 'passed':
132                self._passed += 1
133            elif report.outcome == 'failed':
134                self._failed += 1
135            elif report.outcome == 'skipped':
136                self._skipped += 1
137        elif report.when == 'setup' and report.outcome == 'skipped':
138            # Handle skip during setup (e.g., skip marker)
139            self._results_batch.append((
140                report.nodeid,
141                'skipped',
142                report.duration,
143                getattr(report, 'longreprtext', None)
144            ))
145            self._skipped += 1
146        elif report.when in ('setup', 'teardown') and report.outcome == 'failed':
147            # Handle errors during setup/teardown
148            self._results_batch.append((
149                report.nodeid,
150                'error',
151                report.duration,
152                getattr(report, 'longreprtext', None)
153            ))
154            self._errors += 1
155
156    def pytest_collection_finish(self, session):
157        """Called after collection is complete."""
158        self._collected_count = len(session.items)
159        self.collector.set_collected_count(self._collected_count)
160
161    def pytest_sessionfinish(self, session, exitstatus):
162        """Called after the entire session finishes."""
163        # Single Rust call with all results (instead of one per test)
164        if self._results_batch:
165            self.collector.report_batch(self._results_batch)
166        self.collector.set_exit_status(exitstatus)
167"#;
168
169/// Rust-side result collector that receives results from the pytest plugin.
170#[pyclass]
171pub struct RustResultCollector {
172    results: Arc<Mutex<Vec<TestResult>>>,
173    collected_count: Arc<Mutex<usize>>,
174    exit_status: Arc<Mutex<i32>>,
175}
176
177#[pymethods]
178impl RustResultCollector {
179    #[new]
180    fn new() -> Self {
181        RustResultCollector {
182            results: Arc::new(Mutex::new(Vec::new())),
183            collected_count: Arc::new(Mutex::new(0)),
184            exit_status: Arc::new(Mutex::new(-1)),
185        }
186    }
187
188    /// Called by pytest hook for each test result.
189    #[pyo3(signature = (nodeid, outcome, duration, message=None))]
190    fn report(&self, nodeid: String, outcome: String, duration: f64, message: Option<String>) {
191        let test_outcome = match outcome.as_str() {
192            "passed" => TestOutcome::Passed,
193            "failed" => TestOutcome::Failed,
194            "skipped" => TestOutcome::Skipped,
195            "error" => TestOutcome::Error,
196            "xfail" => TestOutcome::Xfail,
197            "xpass" => TestOutcome::Xpass,
198            _ => TestOutcome::Error,
199        };
200
201        let result = TestResult {
202            node_id: nodeid,
203            outcome: test_outcome,
204            duration_ms: (duration * 1000.0) as u64,
205            message,
206            stdout: None,
207            stderr: None,
208        };
209
210        self.results.lock().push(result);
211    }
212
213    /// Set the total collected test count.
214    fn set_collected_count(&self, count: usize) {
215        *self.collected_count.lock() = count;
216    }
217
218    /// Set the pytest exit status.
219    fn set_exit_status(&self, status: i32) {
220        *self.exit_status.lock() = status;
221    }
222
223    /// OPTIMIZATION: Receive all results in a single batch (reduces FFI overhead)
224    fn report_batch(&self, results: Vec<(String, String, f64, Option<String>)>) {
225        let mut lock = self.results.lock();
226        for (nodeid, outcome, duration, message) in results {
227            let test_outcome = match outcome.as_str() {
228                "passed" => TestOutcome::Passed,
229                "failed" => TestOutcome::Failed,
230                "skipped" => TestOutcome::Skipped,
231                "error" => TestOutcome::Error,
232                "xfail" => TestOutcome::Xfail,
233                "xpass" => TestOutcome::Xpass,
234                _ => TestOutcome::Error,
235            };
236
237            lock.push(TestResult {
238                node_id: nodeid,
239                outcome: test_outcome,
240                duration_ms: (duration * 1000.0) as u64,
241                message,
242                stdout: None,
243                stderr: None,
244            });
245        }
246    }
247}
248
249impl RustResultCollector {
250    /// Extract all collected results.
251    pub fn take_results(&self) -> Vec<TestResult> {
252        std::mem::take(&mut *self.results.lock())
253    }
254
255    /// Get the collected test count.
256    pub fn collected_count(&self) -> usize {
257        *self.collected_count.lock()
258    }
259
260    /// Get the pytest exit status.
261    pub fn exit_status(&self) -> i32 {
262        *self.exit_status.lock()
263    }
264}
265
266/// Configuration for the embedded executor.
267#[derive(Debug, Clone, Default)]
268pub struct EmbeddedExecutorConfig {
269    /// Number of parallel workers (uses xdist if > 1)
270    pub workers: Option<u32>,
271    /// Stop after N failures
272    pub maxfail: Option<u32>,
273    /// Timeout per test in seconds
274    pub test_timeout_secs: u64,
275    /// Extra pytest arguments
276    pub extra_args: Vec<String>,
277}
278
279/// Embedded Python executor that runs pytest directly without subprocess.
280#[derive(Debug)]
281pub struct EmbeddedExecutor {
282    config: EmbeddedExecutorConfig,
283    /// Track whether Python has been initialized
284    initialized: bool,
285    /// Python version info
286    python_version: Option<(u8, u8, u8)>,
287    /// Path to Python interpreter (used to derive virtualenv site-packages)
288    python_path: Option<PathBuf>,
289}
290
291impl EmbeddedExecutor {
292    /// Create a new embedded executor with optional python_path for virtualenv support.
293    pub fn new(python_path: Option<PathBuf>) -> Result<Self> {
294        // MUST happen before any Python::with_gil() call, otherwise the
295        // interpreter can't find the stdlib and aborts with a fatal error.
296        ensure_pythonhome();
297
298        let mut executor = EmbeddedExecutor {
299            config: EmbeddedExecutorConfig::default(),
300            initialized: false,
301            python_version: None,
302            python_path,
303        };
304
305        // Initialize and validate Python
306        executor.initialize()?;
307
308        Ok(executor)
309    }
310
311    /// Configure the executor.
312    pub fn configure(&mut self, config: EmbeddedExecutorConfig) {
313        self.config = config;
314    }
315
316    /// Initialize embedded Python and validate environment.
317    fn initialize(&mut self) -> Result<()> {
318        Python::with_gil(|py| {
319            // Get Python version
320            let version = py.version_info();
321            self.python_version = Some((version.major, version.minor, version.patch));
322
323            // Validate minimum version (3.8+)
324            if version.major < 3 || (version.major == 3 && version.minor < 8) {
325                return Err(DaemonError::Other(format!(
326                    "Python 3.8+ required, found {}.{}.{}",
327                    version.major, version.minor, version.patch
328                )));
329            }
330
331            // FIRST: Setup sys.path with virtualenv packages
332            // This must happen BEFORE importing pytest so we can find it in the venv
333            self.setup_python_path(py)?;
334
335            // THEN: Verify pytest is available (now it can be found in venv)
336            py.import_bound("pytest").map_err(|e| {
337                DaemonError::Other(format!("pytest not installed or not importable: {}", e))
338            })?;
339
340            self.initialized = true;
341            info!(
342                "Embedded Python {}.{}.{} initialized successfully",
343                version.major, version.minor, version.patch
344            );
345
346            Ok(())
347        })
348    }
349
350    /// Setup Python path to include virtual environment packages.
351    ///
352    /// Priority order:
353    /// 1. Derive site-packages from stored python_path (e.g., .venv/bin/python → .venv/lib/pythonX.Y/site-packages)
354    /// 2. Use VIRTUAL_ENV environment variable as fallback
355    ///
356    /// Note: Handles version mismatches by scanning for any pythonX.Y directory
357    fn setup_python_path(&self, py: Python) -> Result<()> {
358        let sys = py
359            .import_bound("sys")
360            .map_err(|e| DaemonError::Other(format!("Failed to import sys: {}", e)))?;
361
362        let path = sys
363            .getattr("path")
364            .map_err(|e| DaemonError::Other(format!("Failed to get sys.path: {}", e)))?;
365
366        let path_list: &Bound<PyList> = path
367            .downcast()
368            .map_err(|e| DaemonError::Other(format!("sys.path is not a list: {}", e)))?;
369
370        // Priority 1: Use stored python_path to derive site-packages
371        // python_path is like /path/to/.venv/bin/python
372        // Derive: /path/to/.venv/lib/pythonX.Y/site-packages
373        if let Some(ref python_path) = self.python_path {
374            // Go up from bin/python to venv root
375            if let Some(venv_root) = python_path.parent().and_then(|p| p.parent()) {
376                if let Some(site_packages) = Self::find_site_packages(venv_root) {
377                    let sp_str = site_packages.to_string_lossy().to_string();
378                    if !path_list.contains(&sp_str).unwrap_or(false) {
379                        path_list.insert(0, &sp_str).map_err(|e| {
380                            DaemonError::Other(format!("Failed to insert into sys.path: {}", e))
381                        })?;
382                        debug!("Added {} to sys.path from python_path", sp_str);
383                    }
384                }
385            }
386        }
387
388        // Priority 2: Check VIRTUAL_ENV environment variable (fallback)
389        if let Ok(venv) = std::env::var("VIRTUAL_ENV") {
390            let venv_root = std::path::Path::new(&venv);
391            if let Some(site_packages) = Self::find_site_packages(venv_root) {
392                let sp_str = site_packages.to_string_lossy().to_string();
393                if !path_list.contains(&sp_str).unwrap_or(false) {
394                    path_list.insert(0, &sp_str).map_err(|e| {
395                        DaemonError::Other(format!("Failed to insert into sys.path: {}", e))
396                    })?;
397                    debug!("Added {} to sys.path from VIRTUAL_ENV", sp_str);
398                }
399            }
400        }
401
402        Ok(())
403    }
404
405    /// Find site-packages directory in a venv by scanning for pythonX.Y directories.
406    /// This handles version mismatches between PyO3's Python and the venv's Python.
407    fn find_site_packages(venv_root: &std::path::Path) -> Option<PathBuf> {
408        let lib_dir = venv_root.join("lib");
409        if !lib_dir.exists() {
410            return None;
411        }
412
413        // Scan for pythonX.Y directories
414        if let Ok(entries) = std::fs::read_dir(&lib_dir) {
415            for entry in entries.flatten() {
416                let name = entry.file_name();
417                let name_str = name.to_string_lossy();
418                if name_str.starts_with("python")
419                    && entry.file_type().map(|t| t.is_dir()).unwrap_or(false)
420                {
421                    let site_packages = entry.path().join("site-packages");
422                    if site_packages.exists() {
423                        debug!("Found site-packages at: {}", site_packages.display());
424                        return Some(site_packages);
425                    }
426                }
427            }
428        }
429
430        None
431    }
432
433    /// Clear test modules from sys.modules to ensure fresh imports.
434    /// OPTIMIZED: Only clears modules we KNOW are test-related (O(test_paths) instead of O(all_modules))
435    fn clear_test_modules(py: Python, test_paths: &[&str]) -> PyResult<()> {
436        let sys = py.import_bound("sys")?;
437        let modules_attr = sys.getattr("modules")?;
438        let modules: &Bound<PyDict> = modules_attr.downcast()?;
439
440        // Pre-allocate for efficiency
441        let mut keys_to_remove = Vec::with_capacity(test_paths.len() * 2);
442
443        for test_path in test_paths {
444            // Convert file path to module path (e.g., "tests/test_foo.py" -> "tests.test_foo")
445            let module_path = test_path.trim_end_matches(".py").replace('/', ".");
446
447            // Check if this exact module exists
448            if modules.contains(&module_path)? {
449                keys_to_remove.push(module_path.clone());
450            }
451
452            // Also check for conftest in same directory
453            if let Some(parent) = std::path::Path::new(test_path).parent() {
454                let parent_str = parent.to_string_lossy();
455                if !parent_str.is_empty() {
456                    let conftest = format!("{}.conftest", parent_str.replace('/', "."));
457                    if modules.contains(&conftest)? {
458                        keys_to_remove.push(conftest);
459                    }
460                } else {
461                    // Root-level conftest
462                    if modules.contains("conftest")? {
463                        keys_to_remove.push("conftest".to_string());
464                    }
465                }
466            }
467        }
468
469        for key in &keys_to_remove {
470            let _ = modules.del_item(key);
471        }
472
473        if !keys_to_remove.is_empty() {
474            debug!(
475                "Cleared {} test modules from sys.modules",
476                keys_to_remove.len()
477            );
478        }
479        Ok(())
480    }
481
482    /// Reset signal handlers to defaults.
483    fn reset_signal_handlers(py: Python) -> PyResult<()> {
484        let signal = py.import_bound("signal")?;
485
486        // Reset SIGALRM (used by pytest-timeout)
487        if let (Ok(sigalrm), Ok(sig_dfl)) = (signal.getattr("SIGALRM"), signal.getattr("SIG_DFL")) {
488            let _ = signal.call_method1("signal", (sigalrm, sig_dfl));
489        }
490
491        // Reset SIGTERM
492        if let (Ok(sigterm), Ok(sig_dfl)) = (signal.getattr("SIGTERM"), signal.getattr("SIG_DFL")) {
493            let _ = signal.call_method1("signal", (sigterm, sig_dfl));
494        }
495
496        debug!("Reset signal handlers");
497        Ok(())
498    }
499
500    /// Reset asyncio event loop for clean state.
501    fn reset_asyncio(py: Python) -> PyResult<()> {
502        if let Ok(asyncio) = py.import_bound("asyncio") {
503            // Create a fresh event loop
504            if let Ok(new_loop) = asyncio.call_method0("new_event_loop") {
505                let _ = asyncio.call_method1("set_event_loop", (new_loop,));
506            }
507        }
508        debug!("Reset asyncio event loop");
509        Ok(())
510    }
511
512    /// Check if pytest-xdist is available.
513    fn is_xdist_available(py: Python) -> bool {
514        py.import_bound("xdist").is_ok()
515    }
516
517    /// Get or create the cached plugin class to avoid recompiling Python on every run.
518    fn get_or_create_plugin_class(py: Python) -> Result<Bound<'_, PyAny>> {
519        if let Some(cached) = CACHED_PLUGIN_CLASS.get() {
520            return Ok(cached.bind(py).clone());
521        }
522
523        let plugin_module = PyModule::from_code_bound(
524            py,
525            COLLECTOR_PLUGIN,
526            "rpytest_collector_plugin.py",
527            "rpytest_collector_plugin",
528        )
529        .map_err(|e| DaemonError::Other(format!("Failed to compile plugin module: {}", e)))?;
530
531        let plugin_class = plugin_module
532            .getattr("RpytestCollectorPlugin")
533            .map_err(|e| DaemonError::Other(format!("Failed to get plugin class: {}", e)))?;
534
535        let _ = CACHED_PLUGIN_CLASS.set(plugin_class.unbind());
536
537        Ok(CACHED_PLUGIN_CLASS.get().unwrap().bind(py).clone())
538    }
539
540    /// Run tests using embedded Python.
541    pub fn run_tests(&self, node_ids: &[String]) -> Result<Vec<TestResult>> {
542        if node_ids.is_empty() {
543            return Ok(Vec::new());
544        }
545
546        if !self.initialized {
547            return Err(DaemonError::Other(
548                "Embedded Python not initialized".to_string(),
549            ));
550        }
551
552        Python::with_gil(|py| {
553            // Cleanup from previous runs
554            let test_paths: Vec<&str> = node_ids
555                .iter()
556                .filter_map(|id| id.split("::").next())
557                .collect();
558
559            // OPTIMIZATION: Skip cleanup on first run - no stale state yet
560            let is_first_run = FIRST_RUN.swap(false, Ordering::SeqCst);
561            if !is_first_run {
562                Self::clear_test_modules(py, &test_paths)?;
563                // Only reset signal handlers if we've run tests before
564                Self::reset_signal_handlers(py)?;
565                // Only reset asyncio if we've run async tests before
566                Self::reset_asyncio(py)?;
567            }
568
569            // Create result collector
570            let collector = Py::new(py, RustResultCollector::new()).map_err(|e| {
571                DaemonError::Other(format!("Failed to create result collector: {}", e))
572            })?;
573
574            // OPTIMIZATION: Cache the plugin class to avoid recompiling Python on every run
575            let plugin_class = Self::get_or_create_plugin_class(py)?;
576
577            let plugin_instance = plugin_class
578                .call1((collector.clone_ref(py),))
579                .map_err(|e| {
580                    DaemonError::Other(format!("Failed to create plugin instance: {}", e))
581                })?;
582
583            // Build pytest arguments
584            let mut args: Vec<String> = node_ids.to_vec();
585
586            // Add verbose output
587            args.push("-v".to_string());
588            args.push("--tb=short".to_string());
589            args.push("--no-header".to_string());
590
591            // Disable cache provider to avoid state pollution
592            args.push("-p".to_string());
593            args.push("no:cacheprovider".to_string());
594
595            // Add maxfail
596            if let Some(maxfail) = self.config.maxfail {
597                args.push(format!("--maxfail={}", maxfail));
598            }
599
600            // Add xdist workers if available and requested
601            if let Some(workers) = self.config.workers {
602                if workers > 1 && Self::is_xdist_available(py) {
603                    args.push("-n".to_string());
604                    args.push(workers.to_string());
605                    args.push("--dist=loadscope".to_string());
606                } else if workers > 1 {
607                    warn!("pytest-xdist not available, running tests sequentially");
608                }
609            }
610
611            // Add extra args
612            args.extend(self.config.extra_args.clone());
613
614            // Convert args to Python list
615            let py_args = PyList::new_bound(py, &args);
616
617            // Create plugins list
618            let plugins = PyList::new_bound(py, [plugin_instance]);
619
620            // Import pytest and run
621            let pytest = py
622                .import_bound("pytest")
623                .map_err(|e| DaemonError::Other(format!("Failed to import pytest: {}", e)))?;
624
625            debug!("Running pytest with {} tests", node_ids.len());
626
627            // Call pytest.main(args, plugins=[plugin])
628            let kwargs = PyDict::new_bound(py);
629            kwargs
630                .set_item("plugins", plugins)
631                .map_err(|e| DaemonError::Other(format!("Failed to set plugins kwarg: {}", e)))?;
632
633            let exit_code: i32 = pytest
634                .call_method("main", (py_args,), Some(&kwargs))
635                .map_err(|e| DaemonError::Other(format!("pytest.main() failed: {}", e)))?
636                .extract()
637                .unwrap_or(-1);
638
639            debug!("pytest.main() completed with exit code {}", exit_code);
640
641            // Extract results from collector
642            let collector_ref = collector.borrow(py);
643            let results = collector_ref.take_results();
644
645            // If no results were collected via the plugin, create error results
646            if results.is_empty() && !node_ids.is_empty() {
647                warn!(
648                    "No results collected from pytest plugin (exit code: {})",
649                    exit_code
650                );
651                // Return error results for all requested tests
652                return Ok(node_ids
653                    .iter()
654                    .map(|id| TestResult {
655                        node_id: id.clone(),
656                        outcome: if exit_code == 0 {
657                            TestOutcome::Passed
658                        } else {
659                            TestOutcome::Error
660                        },
661                        duration_ms: 0,
662                        message: Some(format!("pytest exit code: {}", exit_code)),
663                        stdout: None,
664                        stderr: None,
665                    })
666                    .collect());
667            }
668
669            info!(
670                "Collected {} results from {} tests",
671                results.len(),
672                node_ids.len()
673            );
674
675            Ok(results)
676        })
677    }
678
679    /// Run a single test.
680    pub fn run_test(&self, node_id: &str) -> Result<TestResult> {
681        let results = self.run_tests(&[node_id.to_string()])?;
682        results
683            .into_iter()
684            .next()
685            .ok_or_else(|| DaemonError::Other(format!("No result for test: {}", node_id)))
686    }
687
688    /// Get Python version info.
689    pub fn python_version(&self) -> Option<(u8, u8, u8)> {
690        self.python_version
691    }
692
693    /// Check if embedded Python is available and working.
694    pub fn is_available() -> bool {
695        // Ensure PYTHONHOME is set before touching the interpreter.
696        ensure_pythonhome();
697
698        // Use catch_unwind to prevent a fatal Python init error from
699        // crashing the entire daemon; instead we return false and let
700        // the caller fall back to subprocess mode.
701        std::panic::catch_unwind(|| {
702            Python::with_gil(|py| {
703                // Check Python version
704                let version = py.version_info();
705                if version.major < 3 || (version.major == 3 && version.minor < 8) {
706                    return false;
707                }
708
709                // Check pytest is importable
710                py.import_bound("pytest").is_ok()
711            })
712        })
713        .unwrap_or_else(|_| {
714            warn!("Embedded Python initialization panicked; falling back to subprocess mode");
715            false
716        })
717    }
718}
719
720impl Default for EmbeddedExecutor {
721    fn default() -> Self {
722        Self::new(None).expect("Failed to create EmbeddedExecutor")
723    }
724}
725
726#[async_trait]
727impl TestExecutor for EmbeddedExecutor {
728    async fn run_test(&self, node_id: &str) -> Result<TestResult> {
729        // EmbeddedExecutor::run_test is synchronous, but we wrap it for the async trait
730        EmbeddedExecutor::run_test(self, node_id)
731    }
732
733    async fn run_tests(&self, node_ids: &[String]) -> Vec<TestResult> {
734        // EmbeddedExecutor::run_tests is synchronous
735        match EmbeddedExecutor::run_tests(self, node_ids) {
736            Ok(results) => results,
737            Err(e) => {
738                warn!("Embedded execution failed: {}", e);
739                // Return error results for all tests
740                node_ids
741                    .iter()
742                    .map(|id| TestResult {
743                        node_id: id.clone(),
744                        outcome: TestOutcome::Error,
745                        duration_ms: 0,
746                        message: Some(format!("Embedded execution error: {}", e)),
747                        stdout: None,
748                        stderr: None,
749                    })
750                    .collect()
751            }
752        }
753    }
754
755    fn configure(&mut self, config: ExecutorConfig) {
756        self.config = EmbeddedExecutorConfig {
757            workers: config.workers,
758            maxfail: config.maxfail,
759            test_timeout_secs: config.test_timeout_secs,
760            extra_args: config.extra_args.clone(),
761        };
762    }
763
764    fn execution_mode(&self) -> &'static str {
765        "embedded"
766    }
767
768    fn kill_all(&self) {
769        // Embedded execution doesn't spawn processes, nothing to kill
770        // But we could potentially interrupt the GIL in the future
771    }
772}
773
774#[cfg(test)]
775mod tests {
776    use super::*;
777
778    #[test]
779    fn test_embedded_executor_creation() {
780        // This test will only pass if Python 3.8+ with pytest is available
781        if EmbeddedExecutor::is_available() {
782            let executor = EmbeddedExecutor::new(None);
783            assert!(executor.is_ok());
784
785            let executor = executor.unwrap();
786            let version = executor.python_version();
787            assert!(version.is_some());
788
789            let (major, minor, _) = version.unwrap();
790            assert!(major >= 3);
791            assert!(minor >= 8 || major > 3);
792        }
793    }
794
795    #[test]
796    fn test_rust_result_collector() {
797        Python::with_gil(|py| {
798            let collector = Py::new(py, RustResultCollector::new()).unwrap();
799            let collector_ref = collector.borrow(py);
800
801            // Simulate reporting a result
802            collector_ref.report(
803                "test_file.py::test_func".to_string(),
804                "passed".to_string(),
805                0.123,
806                None,
807            );
808
809            let results = collector_ref.take_results();
810            assert_eq!(results.len(), 1);
811            assert_eq!(results[0].node_id, "test_file.py::test_func");
812            assert!(matches!(results[0].outcome, TestOutcome::Passed));
813            assert_eq!(results[0].duration_ms, 123);
814        });
815    }
816
817    #[test]
818    fn test_rust_result_collector_multiple() {
819        Python::with_gil(|py| {
820            let collector = Py::new(py, RustResultCollector::new()).unwrap();
821            let collector_ref = collector.borrow(py);
822
823            // Report multiple results
824            collector_ref.report(
825                "test_a.py::test_1".to_string(),
826                "passed".to_string(),
827                0.05,
828                None,
829            );
830            collector_ref.report(
831                "test_b.py::test_2".to_string(),
832                "failed".to_string(),
833                0.1,
834                Some("AssertionError".to_string()),
835            );
836            collector_ref.report(
837                "test_c.py::test_3".to_string(),
838                "skipped".to_string(),
839                0.001,
840                Some("reason: not implemented".to_string()),
841            );
842
843            let results = collector_ref.take_results();
844            assert_eq!(results.len(), 3);
845            assert!(matches!(results[0].outcome, TestOutcome::Passed));
846            assert!(matches!(results[1].outcome, TestOutcome::Failed));
847            assert!(matches!(results[2].outcome, TestOutcome::Skipped));
848            assert_eq!(results[1].message, Some("AssertionError".to_string()));
849        });
850    }
851
852    #[test]
853    fn test_rust_result_collector_take_clears() {
854        Python::with_gil(|py| {
855            let collector = Py::new(py, RustResultCollector::new()).unwrap();
856            let collector_ref = collector.borrow(py);
857
858            collector_ref.report(
859                "test.py::test_1".to_string(),
860                "passed".to_string(),
861                0.01,
862                None,
863            );
864
865            // First take should get results
866            let results1 = collector_ref.take_results();
867            assert_eq!(results1.len(), 1);
868
869            // Second take should be empty
870            let results2 = collector_ref.take_results();
871            assert!(results2.is_empty());
872        });
873    }
874
875    #[test]
876    fn test_rust_result_collector_outcomes() {
877        Python::with_gil(|py| {
878            let collector = Py::new(py, RustResultCollector::new()).unwrap();
879            let collector_ref = collector.borrow(py);
880
881            // Test all outcome types
882            let outcomes = vec![
883                ("passed", TestOutcome::Passed),
884                ("failed", TestOutcome::Failed),
885                ("skipped", TestOutcome::Skipped),
886                ("error", TestOutcome::Error),
887                ("xfail", TestOutcome::Xfail),
888                ("xpass", TestOutcome::Xpass),
889            ];
890
891            for (i, (outcome_str, _)) in outcomes.iter().enumerate() {
892                collector_ref.report(
893                    format!("test.py::test_{}", i),
894                    outcome_str.to_string(),
895                    0.01,
896                    None,
897                );
898            }
899
900            let results = collector_ref.take_results();
901            assert_eq!(results.len(), outcomes.len());
902
903            for (i, (_, expected_outcome)) in outcomes.iter().enumerate() {
904                assert_eq!(
905                    results[i].outcome, *expected_outcome,
906                    "Mismatch at index {}",
907                    i
908                );
909            }
910        });
911    }
912
913    #[test]
914    fn test_embedded_executor_execution_mode() {
915        if EmbeddedExecutor::is_available() {
916            let executor = EmbeddedExecutor::new(None).unwrap();
917            assert_eq!(executor.execution_mode(), "embedded");
918        }
919    }
920
921    #[test]
922    fn test_embedded_executor_configure() {
923        use crate::executor::TestExecutor;
924
925        if EmbeddedExecutor::is_available() {
926            let mut executor = EmbeddedExecutor::new(None).unwrap();
927            let config = ExecutorConfig {
928                workers: Some(4),
929                maxfail: Some(10),
930                batch_size: 100,
931                test_timeout_secs: 120,
932                extra_args: vec!["--tb=long".to_string()],
933            };
934            // Use trait method which accepts ExecutorConfig
935            TestExecutor::configure(&mut executor, config);
936            // Configuration should not fail
937            assert_eq!(executor.execution_mode(), "embedded");
938        }
939    }
940
941    #[test]
942    fn test_embedded_executor_kill_all() {
943        if EmbeddedExecutor::is_available() {
944            let executor = EmbeddedExecutor::new(None).unwrap();
945            // Should not panic
946            executor.kill_all();
947        }
948    }
949
950    #[test]
951    fn test_duration_conversion() {
952        Python::with_gil(|py| {
953            let collector = Py::new(py, RustResultCollector::new()).unwrap();
954            let collector_ref = collector.borrow(py);
955
956            // Test duration conversion (seconds to milliseconds)
957            collector_ref.report(
958                "test.py::test_1".to_string(),
959                "passed".to_string(),
960                1.5, // 1.5 seconds
961                None,
962            );
963            collector_ref.report(
964                "test.py::test_2".to_string(),
965                "passed".to_string(),
966                0.001, // 1 millisecond
967                None,
968            );
969
970            let results = collector_ref.take_results();
971            assert_eq!(results[0].duration_ms, 1500);
972            assert_eq!(results[1].duration_ms, 1);
973        });
974    }
975
976    #[test]
977    fn test_embedded_executor_run_passing_test() {
978        use std::fs;
979        use tempfile::TempDir;
980
981        if !EmbeddedExecutor::is_available() {
982            return;
983        }
984
985        // Create a temporary test file
986        let dir = TempDir::new().unwrap();
987        let test_file = dir.path().join("test_example.py");
988        fs::write(&test_file, "def test_passing():\n    assert 1 + 1 == 2\n").unwrap();
989
990        let executor = EmbeddedExecutor::new(None).unwrap();
991        let node_id = format!("{}::test_passing", test_file.to_string_lossy());
992
993        let result = executor.run_test(&node_id);
994        assert!(result.is_ok());
995        let result = result.unwrap();
996        assert!(matches!(result.outcome, TestOutcome::Passed));
997    }
998
999    #[test]
1000    fn test_embedded_executor_run_failing_test() {
1001        use std::fs;
1002        use tempfile::TempDir;
1003
1004        if !EmbeddedExecutor::is_available() {
1005            return;
1006        }
1007
1008        // Create a temporary test file with a failing test
1009        let dir = TempDir::new().unwrap();
1010        let test_file = dir.path().join("test_fail.py");
1011        fs::write(&test_file, "def test_failing():\n    assert 1 == 2\n").unwrap();
1012
1013        let executor = EmbeddedExecutor::new(None).unwrap();
1014        let node_id = format!("{}::test_failing", test_file.to_string_lossy());
1015
1016        let result = executor.run_test(&node_id);
1017        assert!(result.is_ok());
1018        let result = result.unwrap();
1019        assert!(matches!(result.outcome, TestOutcome::Failed));
1020    }
1021
1022    #[test]
1023    fn test_embedded_executor_run_multiple_tests() {
1024        use std::fs;
1025        use tempfile::TempDir;
1026
1027        if !EmbeddedExecutor::is_available() {
1028            return;
1029        }
1030
1031        // Create a temporary test file with multiple tests
1032        let dir = TempDir::new().unwrap();
1033        let test_file = dir.path().join("test_multi.py");
1034        fs::write(
1035            &test_file,
1036            "def test_one():\n    assert True\n\ndef test_two():\n    assert True\n\ndef test_three():\n    assert False\n",
1037        )
1038        .unwrap();
1039
1040        let executor = EmbeddedExecutor::new(None).unwrap();
1041        let file_path = test_file.to_string_lossy();
1042        let node_ids = vec![
1043            format!("{}::test_one", file_path),
1044            format!("{}::test_two", file_path),
1045            format!("{}::test_three", file_path),
1046        ];
1047
1048        let results = executor.run_tests(&node_ids);
1049        assert!(results.is_ok());
1050        let results = results.unwrap();
1051        assert_eq!(results.len(), 3);
1052        assert!(matches!(results[0].outcome, TestOutcome::Passed));
1053        assert!(matches!(results[1].outcome, TestOutcome::Passed));
1054        assert!(matches!(results[2].outcome, TestOutcome::Failed));
1055    }
1056
1057    #[test]
1058    fn test_embedded_executor_run_empty() {
1059        if !EmbeddedExecutor::is_available() {
1060            return;
1061        }
1062
1063        let executor = EmbeddedExecutor::new(None).unwrap();
1064        let results = executor.run_tests(&[]);
1065        assert!(results.is_ok());
1066        assert!(results.unwrap().is_empty());
1067    }
1068
1069    #[test]
1070    fn test_embedded_executor_run_skipped_test() {
1071        use std::fs;
1072        use tempfile::TempDir;
1073
1074        if !EmbeddedExecutor::is_available() {
1075            return;
1076        }
1077
1078        // Create a temporary test file with a skipped test
1079        let dir = TempDir::new().unwrap();
1080        let test_file = dir.path().join("test_skip.py");
1081        fs::write(
1082            &test_file,
1083            "import pytest\n\n@pytest.mark.skip(reason='test skip')\ndef test_skipped():\n    assert True\n",
1084        )
1085        .unwrap();
1086
1087        let executor = EmbeddedExecutor::new(None).unwrap();
1088        let node_id = format!("{}::test_skipped", test_file.to_string_lossy());
1089
1090        let result = executor.run_test(&node_id);
1091        assert!(result.is_ok());
1092        let result = result.unwrap();
1093        assert!(matches!(result.outcome, TestOutcome::Skipped));
1094    }
1095}