use anyhow::Result;
use indicatif::ProgressBar;
use regex::Regex;
use std::collections::HashSet;
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::OnceLock;
use crate::config::PythonAnalyzerConfig;
use crate::deps_cache::DepsCache;
use crate::errors;
use crate::file_index::FileIndex;
use crate::graph::{BuildGraph, Product};
use super::DepAnalyzer;
pub fn scan_python_imports(source: &Path) -> Result<Vec<String>> {
let content = crate::errors::ctx(
fs::read_to_string(source),
&format!("Failed to read Python source: {}", source.display()),
)?;
let mut modules = Vec::new();
static IMPORT_RE: OnceLock<Regex> = OnceLock::new();
let import_re = IMPORT_RE.get_or_init(|| {
Regex::new(r"^\s*(?:from\s+(\S+)\s+import|import\s+(\S+))").expect(errors::INVALID_REGEX)
});
for line in content.lines() {
let trimmed = line.trim();
if trimmed.starts_with('#') {
continue;
}
let Some(caps) = import_re.captures(line) else {
continue;
};
let module_path = caps.get(1).or_else(|| caps.get(2)).map(|m| m.as_str());
let Some(module) = module_path else { continue };
for part in module.split(',') {
let module_name = part.split_whitespace().next().unwrap_or("");
if !module_name.is_empty() {
modules.push(module_name.to_string());
}
}
}
Ok(modules)
}
pub struct PythonDepAnalyzer {
iname: String,
config: PythonAnalyzerConfig,
}
impl PythonDepAnalyzer {
pub fn new(iname: &str, config: PythonAnalyzerConfig) -> Self {
Self {
iname: iname.to_string(),
config,
}
}
fn scan_imports(&self, source: &Path, file_index: &FileIndex) -> Result<Vec<PathBuf>> {
let modules = scan_python_imports(source)?;
let mut imports = Vec::new();
let mut seen = HashSet::new();
for module_name in modules {
if let Some(path) = self.resolve_module(source, &module_name, file_index)
&& !seen.contains(&path)
{
seen.insert(path.clone());
imports.push(path);
}
}
Ok(imports)
}
fn resolve_module(
&self,
source: &Path,
module: &str,
file_index: &FileIndex,
) -> Option<PathBuf> {
let module_path = module.replace('.', "/");
let source_dir = crate::processors::parent_dir(source);
let candidates = [
source_dir.join(format!("{module_path}.py")),
source_dir.join(&module_path).join("__init__.py"),
PathBuf::from(format!("{module_path}.py")),
PathBuf::from(&module_path).join("__init__.py"),
];
for candidate in &candidates {
if file_index.contains(candidate) {
return Some(candidate.clone());
}
if candidate.is_file() {
return Some(candidate.clone());
}
}
None
}
}
impl DepAnalyzer for PythonDepAnalyzer {
fn description(&self) -> &'static str {
"Scan Python source files for import dependencies"
}
fn enabled(&self) -> bool {
self.config.enabled
}
fn auto_detect(&self, file_index: &FileIndex) -> bool {
file_index.has_extension(".py")
}
fn match_product(&self, p: &Product) -> Option<PathBuf> {
if p.inputs.is_empty() {
return None;
}
let source = &p.inputs[0];
let ext = source.extension().and_then(|s| s.to_str()).unwrap_or("");
if ext == "py" {
Some(source.clone())
} else {
None
}
}
fn analyze(
&self,
ctx: &crate::build_context::BuildContext,
graph: &mut BuildGraph,
deps_cache: &mut DepsCache,
file_index: &FileIndex,
_verbose: bool,
progress: &ProgressBar,
) -> Result<()> {
super::analyze_with_scanner(
ctx,
graph,
deps_cache,
&self.iname,
|p| self.match_product(p),
|source| self.scan_imports(source, file_index),
progress,
)
}
}
inventory::submit! {
crate::registries::AnalyzerPlugin {
name: "python",
description: "Scan Python files for local import dependencies",
is_native: true,
create: |iname, toml_value, _| {
let cfg: PythonAnalyzerConfig = toml::from_str(&toml::to_string(toml_value)?)?;
Ok(Box::new(PythonDepAnalyzer::new(iname, cfg)))
},
defconfig_toml: || {
toml::to_string_pretty(&PythonAnalyzerConfig::default()).ok()
},
known_fields: crate::registries::typed_known_fields::<PythonAnalyzerConfig>,
}
}