use crate::cargo_options::CargoOptions;
use crate::{CargoToml, Metadata24, PyProjectToml};
use anyhow::{Context, Result, bail, format_err};
use cargo_metadata::{Metadata, MetadataCommand};
use normpath::PathExt as _;
use std::collections::HashSet;
use std::env;
use std::io;
use std::path::{Path, PathBuf};
use tracing::{debug, instrument, warn};
const PYPROJECT_TOML: &str = "pyproject.toml";
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ProjectLayout {
pub project_root: PathBuf,
pub python_dir: PathBuf,
pub python_module: Option<PathBuf>,
pub python_packages: Vec<String>,
pub rust_module: PathBuf,
pub extension_name: String,
pub data: Option<PathBuf>,
}
#[derive(Clone, Debug)]
pub struct ProjectResolver {
pub project_layout: ProjectLayout,
pub cargo_toml_path: PathBuf,
pub cargo_toml: CargoToml,
pub pyproject_toml_path: PathBuf,
pub pyproject_toml: Option<PyProjectToml>,
pub module_name: String,
pub metadata24: Metadata24,
pub cargo_options: CargoOptions,
pub cargo_metadata: Metadata,
pub pyproject_toml_maturin_options: Vec<&'static str>,
}
impl ProjectResolver {
pub fn resolve(
cargo_manifest_path: Option<PathBuf>,
mut cargo_options: CargoOptions,
editable_install: bool,
pyproject_toml_path: Option<PathBuf>,
) -> Result<Self> {
let (manifest_file, pyproject_file) = if let Some(pyproject_path) = pyproject_toml_path {
let cargo_toml = cargo_manifest_path
.expect("manifest_path must be set when pyproject_toml_path is provided");
let cargo_toml = cargo_toml
.normalize()
.with_context(|| {
format!(
"manifest path `{}` does not exist or is invalid",
cargo_toml.display()
)
})?
.into_path_buf();
(cargo_toml, pyproject_path)
} else {
Self::resolve_manifest_paths(cargo_manifest_path, &cargo_options)?
};
if !manifest_file.is_file() {
bail!(
"{} is not the path to a Cargo.toml",
manifest_file.display()
);
}
debug_assert!(
manifest_file.is_absolute(),
"manifest_file {} is not absolute",
manifest_file.display()
);
debug_assert!(
pyproject_file.is_absolute(),
"pyproject_file {} is not absolute",
pyproject_file.display()
);
cargo_options.manifest_path = Some(manifest_file.clone());
let cargo_toml = CargoToml::from_path(&manifest_file)?;
cargo_toml.check_removed_python_metadata()?;
let manifest_dir = manifest_file.parent().unwrap();
let pyproject_toml: Option<PyProjectToml> = if pyproject_file.is_file() {
let pyproject = PyProjectToml::new(&pyproject_file)?;
pyproject.warn_bad_maturin_version();
pyproject.warn_missing_build_backend();
Some(pyproject)
} else {
None
};
let pyproject = pyproject_toml.as_ref();
let tool_maturin = pyproject.and_then(|p| p.maturin());
let pyproject_toml_maturin_options = if let Some(tool_maturin) = tool_maturin {
cargo_options.merge_with_pyproject_toml(tool_maturin.clone(), editable_install)
} else {
Vec::new()
};
let cargo_metadata = Self::resolve_cargo_metadata(&manifest_file, &cargo_options)?;
let mut metadata24 = Metadata24::from_cargo_toml(manifest_dir, &cargo_metadata)
.context("Failed to parse Cargo.toml into python metadata")?;
if let Some(pyproject) = pyproject {
let pyproject_dir = pyproject_file.parent().unwrap();
metadata24.merge_pyproject_toml(pyproject_dir, pyproject)?;
}
let crate_name = &cargo_toml.package.name;
let module_name = pyproject
.and_then(|x| x.module_name())
.or(cargo_toml.lib.as_ref().and_then(|lib| lib.name.as_deref()))
.or(pyproject
.and_then(|pyproject| pyproject.project.as_ref())
.map(|project| project.name.as_str()))
.unwrap_or(crate_name)
.to_owned();
let project_root = if pyproject_file.is_file() {
pyproject_file.parent().unwrap_or(manifest_dir)
} else {
manifest_dir
};
let python_packages = pyproject
.and_then(|x| x.python_packages())
.unwrap_or_default()
.to_vec();
let py_root = match pyproject.and_then(|x| x.python_source()) {
Some(py_src) => project_root
.join(py_src)
.normalize()
.with_context(|| {
format!(
"python-source is set to `{}` but the directory does not exist. \
Either create the directory or remove the `python-source` setting \
from pyproject.toml.",
py_src.display()
)
})?
.into_path_buf(),
None => match pyproject.and_then(|x| x.project_name()) {
Some(project_name) => {
let rust_cargo_toml_found =
project_root.join("rust").join("Cargo.toml").is_file();
let import_name = project_name.replace('-', "_");
let mut package_init = HashSet::new();
package_init.insert(
project_root
.join("src")
.join(import_name)
.join("__init__.py"),
);
for package in &python_packages {
package_init
.insert(project_root.join("src").join(package).join("__init__.py"));
}
let python_src_found = package_init.iter().any(|x| x.is_file());
if rust_cargo_toml_found && python_src_found {
project_root.join("src")
} else {
project_root.to_path_buf()
}
}
None => project_root.to_path_buf(),
},
};
let data = pyproject.and_then(|x| x.data()).map(|data| {
if data.is_absolute() {
data.to_path_buf()
} else {
project_root.join(data)
}
});
let custom_python_source = pyproject.and_then(|x| x.python_source()).is_some();
let project_layout = ProjectLayout::determine(
project_root,
&module_name,
py_root,
python_packages,
data,
custom_python_source,
)?;
Ok(Self {
project_layout,
cargo_toml_path: manifest_file,
cargo_toml,
pyproject_toml_path: pyproject_file,
pyproject_toml,
module_name,
metadata24,
cargo_options,
cargo_metadata,
pyproject_toml_maturin_options,
})
}
fn resolve_manifest_paths(
cargo_manifest_path: Option<PathBuf>,
cargo_options: &CargoOptions,
) -> Result<(PathBuf, PathBuf)> {
if let Some(path) = cargo_manifest_path {
let path = path
.normalize()
.with_context(|| {
format!(
"manifest path `{}` does not exist or is invalid",
path.display()
)
})?
.into_path_buf();
debug!(
"Using cargo manifest path from command line argument: {:?}",
path
);
let workspace_root = Self::resolve_cargo_metadata(&path, cargo_options)?.workspace_root;
let workspace_parent = workspace_root.parent().unwrap_or(&workspace_root);
for parent in path.ancestors().skip(1) {
if !dunce::simplified(parent).starts_with(workspace_parent) {
break;
}
let pyproject_file = parent.join(PYPROJECT_TOML);
if pyproject_file.is_file() {
debug!("Found pyproject.toml at {:?}", pyproject_file);
return Ok((path, pyproject_file));
}
}
let pyproject_file = path.parent().unwrap().join(PYPROJECT_TOML);
debug!("Trying pyproject.toml at {:?}", pyproject_file);
return Ok((path, pyproject_file));
}
let current_dir = env::current_dir()
.context("Failed to detect current directory ಠ_ಠ")?
.normalize()?
.into_path_buf();
let pyproject_file = current_dir.join(PYPROJECT_TOML);
if pyproject_file.is_file() {
debug!(
"Found pyproject.toml in working directory at {:?}",
pyproject_file
);
let pyproject = PyProjectToml::new(&pyproject_file)?;
if let Some(path) = pyproject.manifest_path() {
debug!("Using cargo manifest path from pyproject.toml {:?}", path);
return Ok((
path.normalize()
.with_context(|| {
format!(
"manifest path `{}` does not exist or is invalid",
path.display()
)
})?
.into_path_buf(),
pyproject_file,
));
} else {
let path = current_dir.join("rust").join("Cargo.toml");
if path.is_file() {
debug!("Python first src-layout detected");
if pyproject.python_source().is_some() {
return Ok((path, pyproject_file));
} else if let Some(project_name) = pyproject.project_name() {
let import_name = project_name.replace('-', "_");
let mut package_init = HashSet::new();
package_init.insert(
current_dir
.join("src")
.join(import_name)
.join("__init__.py"),
);
for package in pyproject.python_packages().unwrap_or_default() {
package_init
.insert(current_dir.join("src").join(package).join("__init__.py"));
}
if package_init.iter().any(|x| x.is_file()) {
return Ok((path, pyproject_file));
}
}
}
}
}
let path = current_dir.join("Cargo.toml");
if path.exists() {
debug!(
"Using cargo manifest path from working directory: {:?}",
path
);
Ok((path, current_dir.join(PYPROJECT_TOML)))
} else {
Err(format_err!(
"Can't find {} (in {})",
path.display(),
current_dir.display()
))
}
}
#[instrument(skip_all)]
fn resolve_cargo_metadata(
manifest_path: &Path,
cargo_options: &CargoOptions,
) -> Result<Metadata> {
debug!("Resolving cargo metadata from {:?}", manifest_path);
let cargo_metadata_extra_args = cargo_options.cargo_metadata_args()?;
let result = MetadataCommand::new()
.cargo_path("cargo")
.manifest_path(manifest_path)
.verbose(true)
.other_options(cargo_metadata_extra_args)
.exec();
let cargo_metadata = match result {
Ok(cargo_metadata) => cargo_metadata,
Err(cargo_metadata::Error::Io(inner)) if inner.kind() == io::ErrorKind::NotFound => {
return Err(inner)
.context("Cargo metadata failed. Do you have cargo in your PATH?");
}
Err(err) => {
return Err(err)
.context("Cargo metadata failed. Does your crate compile with `cargo build`?");
}
};
Ok(cargo_metadata)
}
}
impl ProjectLayout {
fn determine(
project_root: &Path,
module_name: &str,
python_root: PathBuf,
python_packages: Vec<String>,
data: Option<PathBuf>,
custom_python_source: bool,
) -> Result<ProjectLayout> {
let parts: Vec<&str> = module_name.split('.').collect();
let (python_module, rust_module, extension_name) = if parts.len() > 1 {
let mut rust_module = python_root.clone();
rust_module.extend(&parts[0..parts.len() - 1]);
(
python_root.join(parts[0]),
rust_module,
parts[parts.len() - 1].to_string(),
)
} else {
(
python_root.join(module_name),
python_root.join(module_name),
module_name.to_string(),
)
};
let python_module = if !python_module.join("__init__.py").is_file()
&& python_module.join("Cargo.toml").is_file()
{
debug!("No __init__.py file found in {}", python_module.display());
None
} else {
Some(python_module)
};
debug!(
project_root = %project_root.display(),
python_dir = %python_root.display(),
rust_module = %rust_module.display(),
python_module = ?python_module,
extension_name = %extension_name,
module_name = %module_name,
"Project layout resolved"
);
let data = if let Some(data) = data {
if !data.is_dir() {
bail!("No such data directory {}", data.display());
}
Some(data)
} else if project_root.join(format!("{module_name}.data")).is_dir() {
Some(project_root.join(format!("{module_name}.data")))
} else {
None
};
if let Some(python_module) = python_module {
if python_module.is_dir() {
eprintln!("🍹 Building a mixed python/rust project");
Ok(ProjectLayout {
project_root: project_root.to_path_buf(),
python_dir: python_root,
python_packages,
python_module: Some(python_module),
rust_module,
extension_name,
data,
})
} else {
if custom_python_source {
bail!(
"python-source is set to `{}`, but the python module at `{}` \
does not exist. Either create the Python module or remove the \
`python-source` setting from pyproject.toml.",
python_root.display(),
python_module.display()
);
}
Ok(ProjectLayout {
project_root: project_root.to_path_buf(),
python_dir: python_root,
python_packages,
python_module: None,
rust_module: project_root.to_path_buf(),
extension_name,
data,
})
}
} else {
Ok(ProjectLayout {
project_root: project_root.to_path_buf(),
python_dir: python_root,
python_packages,
python_module: None,
rust_module: project_root.to_path_buf(),
extension_name,
data,
})
}
}
}