//! Build wheels from source distributions
//!
//!
use std::fmt::{Display, Formatter};
use std::io;
use std::io::BufRead;
use std::path::{Path, PathBuf};
use std::process::{Command, Output};
use std::str::FromStr;
use std::sync::Arc;
use flate2::read::GzDecoder;
use fs_err as fs;
use fs_err::{DirEntry, File};
use indoc::formatdoc;
use once_cell::sync::Lazy;
use pyproject_toml::{BuildSystem, Project};
use regex::Regex;
use serde::{Deserialize, Serialize};
use tar::Archive;
use tempfile::{tempdir, TempDir};
use thiserror::Error;
use tokio::sync::Mutex;
use tracing::{debug, instrument};
use zip::ZipArchive;
use pep508_rs::Requirement;
use puffin_interpreter::{InterpreterInfo, Virtualenv};
use puffin_traits::BuildContext;
/// e.g. `pygraphviz/graphviz_wrap.c:3020:10: fatal error: graphviz/cgraph.h: No such file or directory`
static MISSING_HEADER_RE: Lazy = Lazy::new(|| {
Regex::new(
r".*\.(c|c..|h|h..):\d+:\d+: fatal error: (?.*\.(h|h..)): No such file or directory"
)
.unwrap()
});
#[derive(Error, Debug)]
pub enum Error {
#[error(transparent)]
IO(#[from] io::Error),
#[error("Failed to read zip file")]
Zip(#[from] zip::result::ZipError),
#[error("Unsupported archive format (extension not recognized): {0}")]
UnsupportedArchiveType(String),
#[error("Invalid source distribution: {0}")]
InvalidSourceDistribution(String),
#[error("Invalid pyproject.toml")]
InvalidPyprojectToml(#[from] toml::de::Error),
#[error("Failed to install requirements from {0}")]
RequirementsInstall(&'static str, #[source] anyhow::Error),
#[error("Failed to create temporary virtual environment")]
Gourgeist(#[from] gourgeist::Error),
#[error("Failed to run {0}")]
CommandFailed(PathBuf, #[source] io::Error),
#[error("{message}:\n--- stdout:\n{stdout}\n--- stderr:\n{stderr}\n---")]
BuildBackend {
message: String,
stdout: String,
stderr: String,
},
/// Nudge the user towards installing the missing dev library
#[error("{message}:\n--- stdout:\n{stdout}\n--- stderr:\n{stderr}\n---")]
MissingHeader {
message: String,
stdout: String,
stderr: String,
#[source]
missing_header_cause: MissingHeaderCause,
},
}
#[derive(Debug, Error)]
pub struct MissingHeaderCause {
header: String,
// I've picked this over the better readable package name to make clear that you need to
// look for the build dependencies of that version or git commit respectively
package_id: String,
}
impl Display for MissingHeaderCause {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
write!(
f,
"This error likely indicates that you need to install a library that provides \"{}\" for {}",
self.header, self.package_id
)
}
}
impl Error {
fn from_command_output(message: String, output: &Output, package_id: &str) -> Self {
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
// In the cases i've seen it was the 5th last line (see test case), 10 seems like a
// reasonable cutoff
if let Some(header) =
stderr.lines().rev().take(10).find_map(|line| {
Some(MISSING_HEADER_RE.captures(line.trim())?["header"].to_string())
})
{
return Self::MissingHeader {
message,
stdout,
stderr,
missing_header_cause: MissingHeaderCause {
header,
package_id: package_id.to_string(),
},
};
}
Self::BuildBackend {
message,
stdout,
stderr,
}
}
}
/// A pyproject.toml as specified in PEP 517
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
#[serde(rename_all = "kebab-case")]
pub struct PyProjectToml {
/// Build-related data
pub build_system: Option,
/// Project metadata
pub project: Option,
}
/// `[build-backend]` from pyproject.toml
struct Pep517Backend {
/// The build backend string such as `setuptools.build_meta:__legacy__` or `maturin` from
/// `build-backend.backend` in pyproject.toml
///
///
backend: String,
/// `build-backend.requirements` in pyproject.toml
requirements: Vec,
///
backend_path: Option>,
}
impl Pep517Backend {
fn backend_import(&self) -> String {
if let Some((path, object)) = self.backend.split_once(':') {
format!("from {path} import {object}")
} else {
format!("import {}", self.backend)
}
}
}
/// Uses an [`Arc`] internally, clone freely
#[derive(Debug, Default, Clone)]
pub struct SourceBuildContext {
/// Cache the first resolution of `pip`, `setuptools` and `wheel` we made for setup.py (and
/// some PEP 517) builds so we can reuse it
setup_py_resolution: Arc>>>,
}
/// Holds the state through a series of PEP 517 frontend to backend calls or a single setup.py
/// invocation.
///
/// This keeps both the temp dir and the result of a potential `prepare_metadata_for_build_wheel`
/// call which changes how we call `build_wheel`.
pub struct SourceBuild {
temp_dir: TempDir,
source_tree: PathBuf,
/// `Some` if this is a PEP 517 build
pep517_backend: Option,
venv: Virtualenv,
/// Populated if `prepare_metadata_for_build_wheel` was called.
///
/// > If the build frontend has previously called prepare_metadata_for_build_wheel and depends
/// > on the wheel resulting from this call to have metadata matching this earlier call, then
/// > it should provide the path to the created .dist-info directory as the metadata_directory
/// > argument. If this argument is provided, then build_wheel MUST produce a wheel with
/// > identical metadata. The directory passed in by the build frontend MUST be identical to the
/// > directory created by prepare_metadata_for_build_wheel, including any unrecognized files
/// > it created.
metadata_directory: Option,
/// Package id such as `foo-1.2.3`, for error reporting
package_id: String,
}
impl SourceBuild {
/// Create a virtual environment in which to build a source distribution, extracting the
/// contents from an archive if necessary.
///
/// `package_id` is for error reporting only.
pub async fn setup(
source: &Path,
subdirectory: Option<&Path>,
interpreter_info: &InterpreterInfo,
build_context: &impl BuildContext,
source_build_context: SourceBuildContext,
package_id: &str,
) -> Result {
let temp_dir = tempdir()?;
// TODO(konstin): Parse and verify filenames
let source_root = if fs::metadata(source)?.is_dir() {
source.to_path_buf()
} else {
debug!("Unpacking for build: {}", source.display());
let extracted = temp_dir.path().join("extracted");
extract_archive(source, &extracted)?
};
let source_tree = if let Some(subdir) = subdirectory {
source_root.join(subdir)
} else {
source_root
};
// Check if we have a PEP 517 build, a legacy setup.py, or an edge case
let build_system = if source_tree.join("pyproject.toml").is_file() {
let pyproject_toml: PyProjectToml =
toml::from_str(&fs::read_to_string(source_tree.join("pyproject.toml"))?)
.map_err(Error::InvalidPyprojectToml)?;
pyproject_toml.build_system
} else {
None
};
let venv = gourgeist::create_venv(
temp_dir.path().join(".venv"),
build_context.base_python(),
interpreter_info,
)?;
// There are packages such as DTLSSocket 0.1.16 that say
// ```toml
// [build-system]
// requires = ["Cython<3", "setuptools", "wheel"]
// ```
// In this case we need to install requires PEP 517 style but then call setup.py in the
// legacy way
let requirements = if let Some(build_system) = &build_system {
let resolved_requirements = build_context
.resolve(&build_system.requires)
.await
.map_err(|err| {
Error::RequirementsInstall("build-system.requires (resolve)", err)
})?;
build_context
.install(&resolved_requirements, &venv)
.await
.map_err(|err| {
Error::RequirementsInstall("build-system.requires (install)", err)
})?;
build_system.requires.clone()
} else {
let requirements = vec![
Requirement::from_str("wheel").unwrap(),
Requirement::from_str("setuptools").unwrap(),
Requirement::from_str("pip").unwrap(),
];
let mut resolution = source_build_context.setup_py_resolution.lock().await;
let resolved_requirements = if let Some(resolved_requirements) = &*resolution {
resolved_requirements.clone()
} else {
let resolved_requirements = build_context
.resolve(&requirements)
.await
.map_err(|err| Error::RequirementsInstall("setup.py build (resolve)", err))?;
*resolution = Some(resolved_requirements.clone());
resolved_requirements
};
build_context
.install(&resolved_requirements, &venv)
.await
.map_err(|err| Error::RequirementsInstall("setup.py build (install)", err))?;
requirements
};
// > If the pyproject.toml file is absent, or the build-backend key is missing, the
// > source tree is not using this specification, and tools should revert to the legacy
// > behaviour of running setup.py (either directly, or by implicitly invoking the
// > setuptools.build_meta:__legacy__ backend).
let pep517_backend = if let Some(build_system) = build_system {
if let Some(backend) = build_system.build_backend {
Some(Pep517Backend {
backend,
backend_path: build_system.backend_path,
requirements,
})
} else {
None
}
} else {
None
};
if let Some(pep517_backend) = &pep517_backend {
create_pep517_build_environment(
&source_tree,
&venv,
pep517_backend,
build_context,
package_id,
)
.await?;
} else {
if !source_tree.join("setup.py").is_file() {
return Err(Error::InvalidSourceDistribution(
"The archive contains neither a pyproject.toml or a setup.py at the top level"
.to_string(),
));
}
}
Ok(Self {
temp_dir,
source_tree,
pep517_backend,
venv,
metadata_directory: None,
package_id: package_id.to_string(),
})
}
/// Try calling `prepare_metadata_for_build_wheel` to get the metadata without executing the
/// actual build
///
/// TODO(konstin): Return the actual metadata instead of the dist-info dir
pub fn get_metadata_without_build(&mut self) -> Result