use std::fs;
use std::path::{Path, PathBuf};
use anyhow::{bail, Context, Result};
#[derive(Debug, Clone)]
pub struct PyModule {
pub path: Vec<String>,
pub source: String,
pub file: PathBuf,
pub is_init: bool,
}
#[derive(Debug)]
pub struct PyPackage {
pub name: String,
pub version: String,
pub modules: Vec<PyModule>,
}
impl PyPackage {
pub fn entry_module(&self) -> Option<&PyModule> {
self.modules
.iter()
.find(|m| m.path.last().map(String::as_str) == Some("__main__"))
.or_else(|| {
self.modules
.iter()
.find(|m| m.source.contains("__name__") && m.source.contains("__main__"))
})
}
}
pub fn sanitize_name(name: &str) -> String {
let mut out: String = name
.chars()
.map(|c| if c.is_ascii_alphanumeric() { c.to_ascii_lowercase() } else { '_' })
.collect();
if out.chars().next().is_some_and(|c| c.is_ascii_digit()) {
out.insert(0, '_');
}
if out.is_empty() {
out.push_str("pypackage");
}
out
}
pub fn discover(path: &Path) -> Result<PyPackage> {
let path = path
.canonicalize()
.with_context(|| format!("cannot access {}", path.display()))?;
if path.is_file() {
if path.extension().and_then(|e| e.to_str()) != Some("py") {
bail!("{} is not a Python file", path.display());
}
let stem = path
.file_stem()
.and_then(|s| s.to_str())
.context("invalid file name")?;
let source = fs::read_to_string(&path)
.with_context(|| format!("reading {}", path.display()))?;
return Ok(PyPackage {
name: sanitize_name(stem),
version: "0.1.0".to_string(),
modules: vec![PyModule {
path: vec![sanitize_name(stem)],
source,
file: path,
is_init: false,
}],
});
}
let (mut name, version) = read_pyproject(&path)?;
let source_root = locate_source_root(&path, name.as_deref())?;
if name.is_none() {
name = source_root
.file_name()
.and_then(|s| s.to_str())
.map(str::to_string);
}
let name = sanitize_name(&name.context("cannot determine package name")?);
let mut modules = Vec::new();
collect_modules(&source_root, &[], &mut modules)?;
if modules.is_empty() {
bail!("no Python modules found under {}", source_root.display());
}
Ok(PyPackage {
name,
version: version.unwrap_or_else(|| "0.1.0".to_string()),
modules,
})
}
fn read_pyproject(root: &Path) -> Result<(Option<String>, Option<String>)> {
let pyproject = root.join("pyproject.toml");
if !pyproject.is_file() {
return Ok((None, None));
}
let text = fs::read_to_string(&pyproject)
.with_context(|| format!("reading {}", pyproject.display()))?;
let value: toml::Value = text
.parse()
.with_context(|| format!("parsing {}", pyproject.display()))?;
let project = value.get("project");
let name = project
.and_then(|p| p.get("name"))
.and_then(|v| v.as_str())
.map(str::to_string);
let version = project
.and_then(|p| p.get("version"))
.and_then(|v| v.as_str())
.map(str::to_string);
Ok((name, version))
}
fn locate_source_root(root: &Path, name: Option<&str>) -> Result<PathBuf> {
if let Some(name) = name {
for candidate in [
root.join(name),
root.join("src").join(name),
root.join(name.replace('-', "_")),
root.join("src").join(name.replace('-', "_")),
] {
if candidate.join("__init__.py").is_file() {
return Ok(candidate);
}
}
}
if root.join("__init__.py").is_file() {
return Ok(root.to_path_buf());
}
for base in [root.to_path_buf(), root.join("src")] {
if base.is_dir() {
let mut package_dirs: Vec<PathBuf> = fs::read_dir(&base)?
.filter_map(|e| e.ok())
.map(|e| e.path())
.filter(|p| p.is_dir() && p.join("__init__.py").is_file())
.collect();
if package_dirs.len() == 1 {
return Ok(package_dirs.remove(0));
}
}
}
let has_py = fs::read_dir(root)?
.filter_map(|e| e.ok())
.any(|e| e.path().extension().and_then(|x| x.to_str()) == Some("py"));
if has_py {
return Ok(root.to_path_buf());
}
bail!(
"cannot locate Python sources under {} (expected a package directory with __init__.py, or .py files)",
root.display()
)
}
fn collect_modules(dir: &Path, prefix: &[String], out: &mut Vec<PyModule>) -> Result<()> {
let mut entries: Vec<_> = fs::read_dir(dir)?.filter_map(|e| e.ok()).collect();
entries.sort_by_key(|e| e.file_name());
for entry in entries {
let path = entry.path();
if path.is_dir() {
if path.join("__init__.py").is_file() {
let sub = sanitize_name(
path.file_name()
.and_then(|s| s.to_str())
.context("invalid directory name")?,
);
let mut nested_prefix = prefix.to_vec();
nested_prefix.push(sub);
collect_modules(&path, &nested_prefix, out)?;
}
continue;
}
if path.extension().and_then(|e| e.to_str()) != Some("py") {
continue;
}
let stem = path
.file_stem()
.and_then(|s| s.to_str())
.context("invalid file name")?;
let source =
fs::read_to_string(&path).with_context(|| format!("reading {}", path.display()))?;
let is_init = stem == "__init__";
let mut module_path = prefix.to_vec();
if !is_init {
module_path.push(sanitize_name(stem));
}
out.push(PyModule {
path: module_path,
source,
file: path,
is_init,
});
}
Ok(())
}