uv build: Catch version mismatch between sdist and wheel (#9633)

When building a wheel from a source distribution or both a source
distribution and a wheel, the versions in their filenames must be the
same.

By inspecting the filenames, we also assert that the filenames from the
build a valid (we don't enforce normalization though, just that uv can
parse them).

Note that we're not yet checking that also the `pyproject.toml` version,
if declared, and METADATA version matches.
This commit is contained in:
konsti 2024-12-04 15:21:01 +01:00 committed by GitHub
parent 42a35b59cd
commit 566c178276
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 94 additions and 18 deletions

View file

@ -6,7 +6,7 @@ use uv_pep440::Version;
pub use build_tag::{BuildTag, BuildTagError};
pub use egg::{EggInfoFilename, EggInfoFilenameError};
pub use extension::{DistExtension, ExtensionError, SourceDistExtension};
pub use source_dist::SourceDistFilename;
pub use source_dist::{SourceDistFilename, SourceDistFilenameError};
pub use wheel::{WheelFilename, WheelFilenameError};
mod build_tag;

View file

@ -2,6 +2,7 @@ use std::borrow::Cow;
use std::fmt::Write as _;
use std::io::Write as _;
use std::path::{Path, PathBuf};
use std::str::FromStr;
use std::{fmt, io};
use anyhow::{Context, Result};
@ -25,11 +26,14 @@ use uv_configuration::{
TrustedHost,
};
use uv_dispatch::{BuildDispatch, SharedState};
use uv_distribution_filename::SourceDistExtension;
use uv_distribution_filename::{
DistFilename, SourceDistExtension, SourceDistFilename, WheelFilename,
};
use uv_distribution_types::{DependencyMetadata, Index, IndexLocations, SourceDist};
use uv_fs::{relative_to, Simplified};
use uv_install_wheel::linker::LinkMode;
use uv_normalize::PackageName;
use uv_pep440::Version;
use uv_python::{
EnvironmentPreference, PythonDownloads, PythonEnvironment, PythonInstallation,
PythonPreference, PythonRequest, PythonVariant, PythonVersionFile, VersionFileDiscoveryOptions,
@ -76,6 +80,12 @@ enum Error {
distribution ending in one of: {1}."
)]
InvalidSourceDistExt(String, uv_distribution_filename::ExtensionError),
#[error("The built source distribution has an invalid filename")]
InvalidBuiltSourceDistFilename(#[source] uv_distribution_filename::SourceDistFilenameError),
#[error("The built wheel has an invalid filename")]
InvalidBuiltWheelFilename(#[source] uv_distribution_filename::WheelFilenameError),
#[error("The source distribution declares version {0}, but the wheel declares version {1}")]
VersionMismatch(Version, Version),
}
/// Build source distributions and wheels.
@ -649,7 +659,7 @@ async fn build_package(
build_results.push(sdist_build.clone());
// Extract the source distribution into a temporary directory.
let path = output_dir.join(sdist_build.filename());
let path = output_dir.join(sdist_build.filename().to_string());
let reader = fs_err::tokio::File::open(&path).await?;
let ext = SourceDistExtension::from_path(path.as_path())
.map_err(|err| Error::InvalidSourceDistExt(path.user_display().to_string(), err))?;
@ -676,6 +686,7 @@ async fn build_package(
subdirectory,
version_id,
build_output,
Some(sdist_build.filename().version()),
)
.await?;
build_results.push(wheel_build);
@ -712,6 +723,7 @@ async fn build_package(
subdirectory,
version_id,
build_output,
None,
)
.await?;
build_results.push(wheel_build);
@ -732,7 +744,6 @@ async fn build_package(
build_output,
)
.await?;
build_results.push(sdist_build);
let wheel_build = build_wheel(
source.path(),
@ -747,8 +758,10 @@ async fn build_package(
subdirectory,
version_id,
build_output,
Some(sdist_build.filename().version()),
)
.await?;
build_results.push(sdist_build);
build_results.push(wheel_build);
}
BuildPlan::WheelFromSdist => {
@ -760,6 +773,14 @@ async fn build_package(
let temp_dir = tempfile::tempdir_in(&output_dir)?;
uv_extract::stream::archive(reader, ext, temp_dir.path()).await?;
// If the source distribution has a version in its filename, check the version.
let version = source
.path()
.file_name()
.and_then(|filename| filename.to_str())
.and_then(|filename| SourceDistFilename::parsed_normalized_filename(filename).ok())
.map(|filename| filename.version);
// Extract the top-level directory from the archive.
let extracted = match uv_extract::strip_component(temp_dir.path()) {
Ok(top_level) => top_level,
@ -780,6 +801,7 @@ async fn build_package(
subdirectory,
version_id,
build_output,
version.as_ref(),
)
.await?;
build_results.push(wheel_build);
@ -837,7 +859,7 @@ async fn build_sdist(
.await??;
BuildMessage::List {
filename: filename.to_string(),
filename: DistFilename::SourceDistFilename(filename),
source_tree: source_tree.to_path_buf(),
file_list,
}
@ -866,7 +888,10 @@ async fn build_sdist(
.to_string();
BuildMessage::Build {
filename,
filename: DistFilename::SourceDistFilename(
SourceDistFilename::parsed_normalized_filename(&filename)
.map_err(Error::InvalidBuiltSourceDistFilename)?,
),
output_dir: output_dir.to_path_buf(),
}
}
@ -896,7 +921,10 @@ async fn build_sdist(
.map_err(Error::BuildDispatch)?;
let filename = builder.build(output_dir).await?;
BuildMessage::Build {
filename,
filename: DistFilename::SourceDistFilename(
SourceDistFilename::parsed_normalized_filename(&filename)
.map_err(Error::InvalidBuiltSourceDistFilename)?,
),
output_dir: output_dir.to_path_buf(),
}
}
@ -920,16 +948,18 @@ async fn build_wheel(
subdirectory: Option<&Path>,
version_id: Option<&str>,
build_output: BuildOutput,
// Used for checking version consistency
version: Option<&Version>,
) -> Result<BuildMessage, Error> {
let build_message = match action {
BuildAction::List => {
let source_tree_ = source_tree.to_path_buf();
let (name, file_list) = tokio::task::spawn_blocking(move || {
let (filename, file_list) = tokio::task::spawn_blocking(move || {
uv_build_backend::list_wheel(&source_tree_, uv_version::version())
})
.await??;
BuildMessage::List {
filename: name.to_string(),
filename: DistFilename::WheelFilename(filename),
source_tree: source_tree.to_path_buf(),
file_list,
}
@ -955,11 +985,10 @@ async fn build_wheel(
uv_version::version(),
)
})
.await??
.to_string();
.await??;
BuildMessage::Build {
filename,
filename: DistFilename::WheelFilename(filename),
output_dir: output_dir.to_path_buf(),
}
}
@ -989,11 +1018,19 @@ async fn build_wheel(
.map_err(Error::BuildDispatch)?;
let filename = builder.build(output_dir).await?;
BuildMessage::Build {
filename,
filename: DistFilename::WheelFilename(
WheelFilename::from_str(&filename).map_err(Error::InvalidBuiltWheelFilename)?,
),
output_dir: output_dir.to_path_buf(),
}
}
};
if let Some(expected) = version {
let actual = build_message.filename().version();
if expected != actual {
return Err(Error::VersionMismatch(expected.clone(), actual.clone()));
}
}
Ok(build_message)
}
@ -1086,19 +1123,19 @@ impl<'a> Source<'a> {
/// We run all builds in parallel, so we wait until all builds are done to show the success messages
/// in order.
#[derive(Debug, Clone, PartialEq, Eq)]
#[derive(Debug, Clone)]
enum BuildMessage {
/// A built wheel or source distribution.
Build {
/// The name of the built distribution.
filename: String,
filename: DistFilename,
/// The location of the built distribution.
output_dir: PathBuf,
},
/// Show the list of files that would be included in a distribution.
List {
/// The name of the build distribution.
filename: String,
filename: DistFilename,
// All source files are relative to the source tree.
source_tree: PathBuf,
// Included file and source file, if not generated.
@ -1108,7 +1145,7 @@ enum BuildMessage {
impl BuildMessage {
/// The filename of the wheel or source distribution.
fn filename(&self) -> &str {
fn filename(&self) -> &DistFilename {
match self {
BuildMessage::Build { filename: name, .. } => name,
BuildMessage::List { filename: name, .. } => name,
@ -1124,7 +1161,11 @@ impl BuildMessage {
writeln!(
printer.stderr(),
"Successfully built {}",
output_dir.join(filename).user_display().bold().cyan()
output_dir
.join(filename.to_string())
.user_display()
.bold()
.cyan()
)?;
}
BuildMessage::List {

View file

@ -1,5 +1,6 @@
use crate::common::{uv_snapshot, TestContext};
use anyhow::Result;
use assert_cmd::assert::OutputAssertExt;
use assert_fs::prelude::*;
use fs_err::File;
use indoc::indoc;
@ -2334,3 +2335,37 @@ fn list_files_errors() -> Result<()> {
"###);
Ok(())
}
#[test]
fn version_mismatch() -> Result<()> {
let context = TestContext::new("3.12");
let anyio_local = current_dir()?.join("../../scripts/packages/anyio_local");
context
.build()
.arg("--sdist")
.arg("--out-dir")
.arg(context.temp_dir.path())
.arg(anyio_local)
.assert()
.success();
let wrong_source_dist = context.temp_dir.child("anyio-1.2.3.tar.gz");
fs_err::rename(
context.temp_dir.child("anyio-4.3.0+foo.tar.gz"),
&wrong_source_dist,
)?;
uv_snapshot!(context.filters(), context.build()
.arg(wrong_source_dist.path())
.arg("--wheel")
.arg("--out-dir")
.arg(context.temp_dir.path()), @r###"
success: false
exit_code: 2
----- stdout -----
----- stderr -----
Building wheel from source distribution...
× Failed to build `[TEMP_DIR]/anyio-1.2.3.tar.gz`
The source distribution declares version 1.2.3, but the wheel declares version 4.3.0+foo
"###);
Ok(())
}