python_proto_importer/verification/
import_test.rs

1use crate::config::AppConfig;
2use crate::utils::run_cmd;
3use crate::verification::{
4    create_import_test_script, determine_package_structure, determine_package_structure_legacy,
5};
6use anyhow::{Context, Result};
7use std::ffi::OsStr;
8use std::path::Path;
9use walkdir::WalkDir;
10
11/// Run comprehensive import verification for generated Python modules
12pub fn verify(cfg: &AppConfig) -> Result<()> {
13    let out_abs = cfg.out.canonicalize().unwrap_or_else(|_| cfg.out.clone());
14    let mut modules: Vec<String> = Vec::new();
15    let py_suffixes: Vec<&str> = cfg
16        .postprocess
17        .module_suffixes
18        .iter()
19        .filter_map(|s| {
20            if s.ends_with(".py") {
21                Some(s.as_str())
22            } else {
23                None
24            }
25        })
26        .collect();
27
28    for entry in WalkDir::new(&out_abs).into_iter().filter_map(Result::ok) {
29        let path = entry.path();
30        if path.is_file()
31            && path.extension() == Some(OsStr::new("py"))
32            && path.file_name() != Some(OsStr::new("__init__.py"))
33        {
34            let rel = path.strip_prefix(&out_abs).unwrap_or(path);
35            let rel_str = rel.to_string_lossy();
36            if !py_suffixes.is_empty() && !py_suffixes.iter().any(|s| rel_str.ends_with(s)) {
37                continue;
38            }
39            let rel_no_ext = rel.with_extension("");
40            let mut parts: Vec<String> = Vec::new();
41            for comp in rel_no_ext.components() {
42                if let std::path::Component::Normal(os) = comp {
43                    parts.push(os.to_string_lossy().to_string());
44                }
45            }
46            if !parts.is_empty() {
47                modules.push(parts.join("."));
48            }
49        }
50    }
51
52    modules.sort();
53
54    if modules.is_empty() {
55        tracing::info!("no python modules found for verification");
56    } else {
57        let (parent_path, package_name) = determine_package_structure(&out_abs)?;
58
59        tracing::debug!(
60            "using parent_path={}, package_name={}",
61            parent_path.display(),
62            package_name
63        );
64
65        let test_script = create_import_test_script(&package_name, &modules);
66
67        // In debug mode, save the test script to a temporary file for inspection
68        if tracing::enabled!(tracing::Level::DEBUG)
69            && let Ok(temp_dir) = std::env::temp_dir().canonicalize()
70        {
71            let script_path = temp_dir.join(format!(
72                "python_proto_importer_test_{}.py",
73                std::process::id()
74            ));
75            if let Err(e) = std::fs::write(&script_path, &test_script) {
76                tracing::debug!(
77                    "failed to write debug script to {}: {}",
78                    script_path.display(),
79                    e
80                );
81            } else {
82                tracing::debug!(
83                    "comprehensive test script saved to: {}",
84                    script_path.display()
85                );
86            }
87        }
88
89        let mut cmd = std::process::Command::new(&cfg.python_exe);
90        if cfg.python_exe == "uv" {
91            cmd.arg("run").arg("python").arg("-c").arg(&test_script);
92        } else {
93            cmd.arg("-c").arg(&test_script);
94        }
95
96        let output = cmd
97            .env("PYTHONPATH", &parent_path)
98            .output()
99            .with_context(|| {
100                format!(
101                    "failed running {} for package-aware import dry-run",
102                    cfg.python_exe
103                )
104            })?;
105
106        let stderr_output = String::from_utf8_lossy(&output.stderr);
107        for line in stderr_output.lines() {
108            if line.starts_with("IMPORT_TEST_SUMMARY:") {
109                tracing::debug!(
110                    "{}",
111                    line.strip_prefix("IMPORT_TEST_SUMMARY:").unwrap_or(line)
112                );
113            } else if line.starts_with("IMPORT_TEST_SUCCESS:") {
114                tracing::debug!(
115                    "comprehensive import test: {}",
116                    line.strip_prefix("IMPORT_TEST_SUCCESS:")
117                        .unwrap_or("success")
118                );
119            } else if line.starts_with("IMPORT_ERROR:") {
120                tracing::warn!(
121                    "import issue detected: {}",
122                    line.strip_prefix("IMPORT_ERROR:").unwrap_or(line)
123                );
124            }
125        }
126
127        if !output.status.success() {
128            tracing::warn!(
129                "comprehensive import test failed, running individual fallback tests for detailed diagnosis"
130            );
131            let failed_modules =
132                run_individual_fallback_tests(cfg, &parent_path, &package_name, &modules)?;
133            if !failed_modules.is_empty() {
134                // Try legacy package structure determination as a fallback
135                tracing::warn!("retrying with legacy package structure determination...");
136                let (legacy_parent_path, legacy_package_name) =
137                    determine_package_structure_legacy(&out_abs)?;
138
139                if legacy_parent_path != parent_path || legacy_package_name != package_name {
140                    tracing::debug!(
141                        "legacy fallback: parent_path={}, package_name={}",
142                        legacy_parent_path.display(),
143                        legacy_package_name
144                    );
145                    let legacy_failed_modules = run_individual_fallback_tests(
146                        cfg,
147                        &legacy_parent_path,
148                        &legacy_package_name,
149                        &modules,
150                    )?;
151
152                    if legacy_failed_modules.is_empty() {
153                        tracing::info!(
154                            "import dry-run passed with legacy package structure ({} modules)",
155                            modules.len()
156                        );
157                    } else if legacy_failed_modules.len() < failed_modules.len() {
158                        tracing::warn!(
159                            "legacy fallback reduced failures from {} to {} modules",
160                            failed_modules.len(),
161                            legacy_failed_modules.len()
162                        );
163                        for (m, error) in &legacy_failed_modules {
164                            tracing::error!(module=%m, "import failed (legacy fallback): {}", error);
165                        }
166                        anyhow::bail!(
167                            "import dry-run failed for {} modules (out of {}) even with legacy fallback. Use -v for more details.",
168                            legacy_failed_modules.len(),
169                            modules.len()
170                        );
171                    } else {
172                        tracing::warn!(
173                            "legacy fallback did not improve results, showing original errors"
174                        );
175                        for (m, error) in &failed_modules {
176                            tracing::error!(module=%m, "import failed: {}", error);
177                        }
178                        anyhow::bail!(
179                            "import dry-run failed for {} modules (out of {}). Use -v for more details.",
180                            failed_modules.len(),
181                            modules.len()
182                        );
183                    }
184                } else {
185                    tracing::debug!("legacy fallback would use same configuration, skipping");
186                    for (m, error) in &failed_modules {
187                        tracing::error!(module=%m, "import failed: {}", error);
188                    }
189                    anyhow::bail!(
190                        "import dry-run failed for {} modules (out of {}). Use -v for more details.",
191                        failed_modules.len(),
192                        modules.len()
193                    );
194                }
195            }
196            tracing::warn!(
197                "comprehensive test failed but individual tests passed - this may indicate a package structure issue"
198            );
199        }
200
201        tracing::info!("import dry-run passed ({} modules)", modules.len());
202    }
203
204    if let Some(v) = &cfg.verify {
205        if let Some(cmd) = v.mypy_cmd.as_deref().filter(|cmd| !cmd.is_empty()) {
206            run_cmd(cmd).context("mypy_cmd failed")?;
207        }
208        if let Some(cmd) = v.pyright_cmd.as_deref().filter(|cmd| !cmd.is_empty()) {
209            run_cmd(cmd).context("pyright_cmd failed")?;
210        }
211    }
212    Ok(())
213}
214
215/// Run individual fallback tests for each module to provide detailed diagnosis
216fn run_individual_fallback_tests(
217    cfg: &AppConfig,
218    parent_path: &Path,
219    package_name: &str,
220    modules: &[String],
221) -> Result<Vec<(String, String)>> {
222    let mut failed = Vec::new();
223
224    tracing::debug!(
225        "running individual fallback tests for {} modules",
226        modules.len()
227    );
228    tracing::debug!(
229        "environment: PYTHONPATH={}, package_name={}, python_exe={}",
230        parent_path.display(),
231        package_name,
232        cfg.python_exe
233    );
234
235    for (idx, module) in modules.iter().enumerate() {
236        let full_module = if package_name.is_empty() {
237            module.clone()
238        } else {
239            format!("{}.{}", package_name, module)
240        };
241
242        tracing::trace!(
243            "testing individual module ({}/{}): {}",
244            idx + 1,
245            modules.len(),
246            full_module
247        );
248
249        let test_script = format!(
250            r#"
251import sys
252import importlib
253import traceback
254
255module_name = '{}'
256full_module_name = '{}'
257
258try:
259    mod = importlib.import_module(full_module_name)
260    print('SUCCESS:' + module_name, file=sys.stderr)
261except ImportError as e:
262    error_msg = str(e)
263    if "relative import" in error_msg.lower():
264        print('RELATIVE_IMPORT_ERROR:' + module_name + ':' + error_msg, file=sys.stderr)
265    else:
266        print('IMPORT_ERROR:' + module_name + ':' + error_msg, file=sys.stderr)
267except ModuleNotFoundError as e:
268    print('MODULE_NOT_FOUND_ERROR:' + module_name + ':' + str(e), file=sys.stderr)
269except SyntaxError as e:
270    print('SYNTAX_ERROR:' + module_name + ':line ' + str(e.lineno or '?') + ': ' + str(e), file=sys.stderr)
271except Exception as e:
272    print('GENERAL_ERROR:' + module_name + ':' + type(e).__name__ + ': ' + str(e), file=sys.stderr)
273    traceback.print_exc(file=sys.stderr)
274"#,
275            module, full_module
276        );
277
278        // In debug mode, save individual test scripts to temporary files for inspection
279        if tracing::enabled!(tracing::Level::TRACE)
280            && let Ok(temp_dir) = std::env::temp_dir().canonicalize()
281        {
282            let script_path = temp_dir.join(format!(
283                "python_proto_importer_individual_{}_{}.py",
284                std::process::id(),
285                idx
286            ));
287            if let Err(e) = std::fs::write(&script_path, &test_script) {
288                tracing::trace!(
289                    "failed to write debug script to {}: {}",
290                    script_path.display(),
291                    e
292                );
293            } else {
294                tracing::trace!("individual test script saved to: {}", script_path.display());
295            }
296        }
297
298        let mut cmd = std::process::Command::new(&cfg.python_exe);
299        if cfg.python_exe == "uv" {
300            cmd.arg("run").arg("python").arg("-c").arg(&test_script);
301        } else {
302            cmd.arg("-c").arg(&test_script);
303        }
304
305        let output = cmd
306            .env("PYTHONPATH", parent_path)
307            .output()
308            .with_context(|| {
309                format!(
310                    "failed running {} for individual fallback test",
311                    cfg.python_exe
312                )
313            })?;
314
315        if !output.status.success() {
316            let stderr_output = String::from_utf8_lossy(&output.stderr);
317            let stdout_output = String::from_utf8_lossy(&output.stdout);
318            let mut error_msg = String::new();
319
320            // Debug output of full stderr and stdout in verbose mode
321            if tracing::enabled!(tracing::Level::DEBUG) {
322                tracing::debug!("individual test failed for module {}", module);
323                tracing::debug!("exit code: {:?}", output.status.code());
324                if !stderr_output.trim().is_empty() {
325                    tracing::debug!("stderr:\n{}", stderr_output);
326                }
327                if !stdout_output.trim().is_empty() {
328                    tracing::debug!("stdout:\n{}", stdout_output);
329                }
330            }
331
332            // Parse stderr for known error patterns
333            for line in stderr_output.lines() {
334                if line.starts_with("RELATIVE_IMPORT_ERROR:") {
335                    error_msg = format!(
336                        "Relative import issue: {}",
337                        line.strip_prefix("RELATIVE_IMPORT_ERROR:").unwrap_or(line)
338                    );
339                    break;
340                } else if line.starts_with("IMPORT_ERROR:") {
341                    error_msg = format!(
342                        "Import error: {}",
343                        line.strip_prefix("IMPORT_ERROR:").unwrap_or(line)
344                    );
345                    break;
346                } else if line.starts_with("MODULE_NOT_FOUND_ERROR:") {
347                    error_msg = format!(
348                        "Module not found: {}",
349                        line.strip_prefix("MODULE_NOT_FOUND_ERROR:").unwrap_or(line)
350                    );
351                    break;
352                } else if line.starts_with("SYNTAX_ERROR:") {
353                    error_msg = format!(
354                        "Syntax error: {}",
355                        line.strip_prefix("SYNTAX_ERROR:").unwrap_or(line)
356                    );
357                    break;
358                } else if line.starts_with("GENERAL_ERROR:") {
359                    error_msg = format!(
360                        "General error: {}",
361                        line.strip_prefix("GENERAL_ERROR:").unwrap_or(line)
362                    );
363                    break;
364                }
365                // Also check for common Python error patterns in stderr
366                else if line.contains("ImportError:") {
367                    error_msg = format!("ImportError found in stderr: {}", line.trim());
368                    break;
369                } else if line.contains("ModuleNotFoundError:") {
370                    error_msg = format!("ModuleNotFoundError found in stderr: {}", line.trim());
371                    break;
372                } else if line.contains("SyntaxError:") {
373                    error_msg = format!("SyntaxError found in stderr: {}", line.trim());
374                    break;
375                } else if line.contains("NameError:") {
376                    error_msg = format!("NameError found in stderr: {}", line.trim());
377                    break;
378                }
379            }
380
381            // If no error pattern found in stderr, check stdout
382            if error_msg.is_empty() {
383                for line in stdout_output.lines() {
384                    if line.contains("ImportError:") {
385                        error_msg = format!("ImportError found in stdout: {}", line.trim());
386                        break;
387                    } else if line.contains("ModuleNotFoundError:") {
388                        error_msg = format!("ModuleNotFoundError found in stdout: {}", line.trim());
389                        break;
390                    } else if line.contains("SyntaxError:") {
391                        error_msg = format!("SyntaxError found in stdout: {}", line.trim());
392                        break;
393                    } else if line.contains("Traceback (most recent call last):") {
394                        error_msg = format!("Python traceback found in stdout: {}", line.trim());
395                        break;
396                    }
397                }
398            }
399
400            // If still no specific error found, provide more detailed information
401            if error_msg.is_empty() {
402                let detailed_info =
403                    if !stderr_output.trim().is_empty() || !stdout_output.trim().is_empty() {
404                        let stderr_preview =
405                            stderr_output.lines().take(2).collect::<Vec<_>>().join("; ");
406                        let stdout_preview =
407                            stdout_output.lines().take(2).collect::<Vec<_>>().join("; ");
408                        format!(
409                            "Unknown error (exit code: {}) - stderr: '{}' - stdout: '{}'",
410                            output.status.code().unwrap_or(-1),
411                            stderr_preview.trim(),
412                            stdout_preview.trim()
413                        )
414                    } else {
415                        format!(
416                            "Unknown error (exit code: {}) - no output",
417                            output.status.code().unwrap_or(-1)
418                        )
419                    };
420                error_msg = detailed_info;
421            }
422
423            failed.push((module.clone(), error_msg));
424        } else {
425            tracing::trace!("individual test passed: {}", module);
426        }
427    }
428
429    tracing::debug!(
430        "individual fallback tests completed: {}/{} failed",
431        failed.len(),
432        modules.len()
433    );
434    Ok(failed)
435}