diff --git a/crates/uv-distribution-types/src/buildable.rs b/crates/uv-distribution-types/src/buildable.rs index ae6897b4c..27e7a1c0b 100644 --- a/crates/uv-distribution-types/src/buildable.rs +++ b/crates/uv-distribution-types/src/buildable.rs @@ -36,6 +36,7 @@ impl BuildableSource<'_> { pub fn version(&self) -> Option<&Version> { match self { Self::Dist(SourceDist::Registry(dist)) => Some(&dist.version), + Self::Dist(SourceDist::Path(dist)) => dist.version.as_ref(), Self::Dist(_) => None, Self::Url(_) => None, } diff --git a/crates/uv-distribution-types/src/lib.rs b/crates/uv-distribution-types/src/lib.rs index 974e77eed..97fed03b0 100644 --- a/crates/uv-distribution-types/src/lib.rs +++ b/crates/uv-distribution-types/src/lib.rs @@ -39,7 +39,9 @@ use std::str::FromStr; use url::Url; -use uv_distribution_filename::{DistExtension, SourceDistExtension, WheelFilename}; +use uv_distribution_filename::{ + DistExtension, SourceDistExtension, SourceDistFilename, WheelFilename, +}; use uv_fs::normalize_absolute_path; use uv_git::GitUrl; use uv_normalize::PackageName; @@ -312,6 +314,7 @@ pub struct GitSourceDist { #[derive(Debug, Clone, Hash, PartialEq, Eq)] pub struct PathSourceDist { pub name: PackageName, + pub version: Option, /// The absolute path to the distribution which we use for installing. pub install_path: PathBuf, /// The file extension, e.g. `tar.gz`, `zip`, etc. @@ -410,12 +413,24 @@ impl Dist { url, }))) } - DistExtension::Source(ext) => Ok(Self::Source(SourceDist::Path(PathSourceDist { - name, - install_path, - ext, - url, - }))), + DistExtension::Source(ext) => { + // If there is a version in the filename, record it. + let version = url + .filename() + .ok() + .and_then(|filename| { + SourceDistFilename::parse(filename.as_ref(), ext, &name).ok() + }) + .map(|filename| filename.version); + + Ok(Self::Source(SourceDist::Path(PathSourceDist { + name, + version, + install_path, + ext, + url, + }))) + } } } diff --git a/crates/uv-distribution/src/source/mod.rs b/crates/uv-distribution/src/source/mod.rs index c6c1337fc..54cac0b7f 100644 --- a/crates/uv-distribution/src/source/mod.rs +++ b/crates/uv-distribution/src/source/mod.rs @@ -1922,7 +1922,7 @@ impl<'a, T: BuildContext> SourceDistributionBuilder<'a, T> { subdirectory: Option<&Path>, ) -> Result, Error> { // Attempt to read static metadata from the `pyproject.toml`. - match read_pyproject_toml(source_root, subdirectory).await { + match read_pyproject_toml(source_root, subdirectory, source.version()).await { Ok(metadata) => { debug!("Found static `pyproject.toml` for: {source}"); @@ -2345,6 +2345,7 @@ async fn read_pkg_info( async fn read_pyproject_toml( source_tree: &Path, subdirectory: Option<&Path>, + sdist_version: Option<&Version>, ) -> Result { // Read the `pyproject.toml` file. let pyproject_toml = match subdirectory { @@ -2360,8 +2361,8 @@ async fn read_pyproject_toml( }; // Parse the metadata. - let metadata = - ResolutionMetadata::parse_pyproject_toml(&content).map_err(Error::PyprojectToml)?; + let metadata = ResolutionMetadata::parse_pyproject_toml(&content, sdist_version) + .map_err(Error::PyprojectToml)?; Ok(metadata) } diff --git a/crates/uv-pypi-types/src/metadata/metadata_resolver.rs b/crates/uv-pypi-types/src/metadata/metadata_resolver.rs index 9699a247e..2ca03ddf6 100644 --- a/crates/uv-pypi-types/src/metadata/metadata_resolver.rs +++ b/crates/uv-pypi-types/src/metadata/metadata_resolver.rs @@ -151,8 +151,11 @@ impl ResolutionMetadata { }) } - pub fn parse_pyproject_toml(toml: &str) -> Result { - parse_pyproject_toml(toml) + pub fn parse_pyproject_toml( + toml: &str, + sdist_version: Option<&Version>, + ) -> Result { + parse_pyproject_toml(toml, sdist_version) } } diff --git a/crates/uv-pypi-types/src/metadata/pyproject_toml.rs b/crates/uv-pypi-types/src/metadata/pyproject_toml.rs index 4abbd7bff..3a6201aa7 100644 --- a/crates/uv-pypi-types/src/metadata/pyproject_toml.rs +++ b/crates/uv-pypi-types/src/metadata/pyproject_toml.rs @@ -12,7 +12,13 @@ use uv_pep440::{Version, VersionSpecifiers}; use uv_pep508::Requirement; /// Extract the metadata from a `pyproject.toml` file, as specified in PEP 621. -pub(crate) fn parse_pyproject_toml(contents: &str) -> Result { +/// +/// If we're coming from a source distribution, we may already know the version (unlike for a source +/// tree), so we can tolerate dynamic versions. +pub(crate) fn parse_pyproject_toml( + contents: &str, + sdist_version: Option<&Version>, +) -> Result { let pyproject_toml = PyProjectToml::from_toml(contents)?; let project = pyproject_toml @@ -28,7 +34,11 @@ pub(crate) fn parse_pyproject_toml(contents: &str) -> Result return Err(MetadataError::DynamicField("requires-python")), - "version" => return Err(MetadataError::DynamicField("version")), + // When building from a source distribution, the version is known from the filename and + // fixed by it, so we can pretend it's static. + "version" if sdist_version.is_none() => { + return Err(MetadataError::DynamicField("version")) + } _ => (), } } @@ -44,6 +54,9 @@ pub(crate) fn parse_pyproject_toml(contents: &str) -> Result=3.6".parse().unwrap())); @@ -281,7 +294,7 @@ mod tests { requires-python = ">=3.6" dependencies = ["foo"] "#; - let meta = parse_pyproject_toml(s).unwrap(); + let meta = parse_pyproject_toml(s, None).unwrap(); assert_eq!(meta.name, PackageName::from_str("asdf").unwrap()); assert_eq!(meta.version, Version::new([1, 0])); assert_eq!(meta.requires_python, Some(">=3.6".parse().unwrap())); @@ -298,7 +311,7 @@ mod tests { [project.optional-dependencies] dotenv = ["bar"] "#; - let meta = parse_pyproject_toml(s).unwrap(); + let meta = parse_pyproject_toml(s, None).unwrap(); assert_eq!(meta.name, PackageName::from_str("asdf").unwrap()); assert_eq!(meta.version, Version::new([1, 0])); assert_eq!(meta.requires_python, Some(">=3.6".parse().unwrap())); diff --git a/crates/uv-resolver/src/lock/mod.rs b/crates/uv-resolver/src/lock/mod.rs index 2ffb68ca7..4520d5548 100644 --- a/crates/uv-resolver/src/lock/mod.rs +++ b/crates/uv-resolver/src/lock/mod.rs @@ -1784,6 +1784,7 @@ impl Package { }; let path_dist = PathSourceDist { name: self.id.name.clone(), + version: Some(self.id.version.clone()), url: verbatim_url(workspace_root.join(path), &self.id)?, install_path: workspace_root.join(path), ext, diff --git a/crates/uv/tests/it/pip_compile.rs b/crates/uv/tests/it/pip_compile.rs index 39af23050..d0087ebe9 100644 --- a/crates/uv/tests/it/pip_compile.rs +++ b/crates/uv/tests/it/pip_compile.rs @@ -2,10 +2,13 @@ use std::env::current_dir; use std::fs; +use std::io::Cursor; use std::path::PathBuf; use anyhow::{bail, Context, Result}; use assert_fs::prelude::*; +use flate2::write::GzEncoder; +use fs_err::File; use indoc::indoc; use url::Url; @@ -13858,3 +13861,70 @@ fn compile_lowest_extra_unpinned_warning() -> Result<()> { Ok(()) } + +/// Test that we use the version in the source distribution filename for compiling, even if the +/// version is declared as dynamic. +/// +/// `test_dynamic_version_sdist_wrong_version` checks that this version must be correct. +#[test] +fn dynamic_version_source_dist() -> Result<()> { + let context = TestContext::new("3.12"); + + // Write a source dist that has a version in its name, a dynamic version in pyproject.toml + // and check that we don't build it when compiling. + let pyproject_toml = r#" + [project] + name = "foo" + requires-python = ">=3.9" + dependencies = [] + dynamic = ["version"] + "#; + + let setup_py = "boom()"; + + let source_dist = context.temp_dir.child("foo-1.2.3.tar.gz"); + // Flush the file after we're done. + { + let file = File::create(source_dist.path())?; + let enc = GzEncoder::new(file, flate2::Compression::default()); + let mut tar = tar::Builder::new(enc); + + for (path, contents) in [ + ("foo-1.2.3/pyproject.toml", pyproject_toml), + ("foo-1.2.3/setup.py", setup_py), + ] { + let mut header = tar::Header::new_gnu(); + header.set_size(contents.len() as u64); + header.set_mode(0o644); + header.set_cksum(); + tar.append_data(&mut header, path, Cursor::new(contents))?; + } + tar.finish()?; + } + + let requirements_in = context.temp_dir.child("requirements.in"); + requirements_in.write_str(indoc::indoc! {r" + foo + "})?; + + uv_snapshot!(context.filters(), context + .pip_compile() + .arg(requirements_in.path()) + .arg("--no-index") + .arg("--find-links") + .arg(context.temp_dir.path()), @r###" + success: true + exit_code: 0 + ----- stdout ----- + # This file was autogenerated by uv via the following command: + # uv pip compile --cache-dir [CACHE_DIR] [TEMP_DIR]/requirements.in --no-index + foo==1.2.3 + # via -r requirements.in + + ----- stderr ----- + Resolved 1 package in [TIME] + "### + ); + + Ok(()) +} diff --git a/crates/uv/tests/it/pip_install.rs b/crates/uv/tests/it/pip_install.rs index a57b775fc..7f7104ea4 100644 --- a/crates/uv/tests/it/pip_install.rs +++ b/crates/uv/tests/it/pip_install.rs @@ -1,9 +1,12 @@ +use std::io::Cursor; use std::process::Command; use anyhow::Result; use assert_cmd::prelude::*; use assert_fs::prelude::*; +use flate2::write::GzEncoder; use fs_err as fs; +use fs_err::File; use indoc::indoc; use predicates::prelude::predicate; use url::Url; @@ -7455,3 +7458,61 @@ fn respect_no_installer_metadata_env_var() { .join("INSTALLER"); assert!(!installer_file.exists()); } + +/// Check that we error if a source dist lies about its built wheel version. +#[test] +fn test_dynamic_version_sdist_wrong_version() -> Result<()> { + let context = TestContext::new("3.12"); + + // Write a source dist that has a version in its name, a dynamic version in pyproject.toml, + // but reports the wrong version when built. + let pyproject_toml = r#" + [project] + name = "foo" + requires-python = ">=3.9" + dependencies = [] + dynamic = ["version"] + "#; + + let setup_py = indoc! {r#" + from setuptools import setup + + setup(name="foo", version="10.11.12") + "#}; + + let source_dist = context.temp_dir.child("foo-1.2.3.tar.gz"); + // Flush the file after we're done. + { + let file = File::create(source_dist.path())?; + let enc = GzEncoder::new(file, flate2::Compression::default()); + let mut tar = tar::Builder::new(enc); + + for (path, contents) in [ + ("foo-1.2.3/pyproject.toml", pyproject_toml), + ("foo-1.2.3/setup.py", setup_py), + ] { + let mut header = tar::Header::new_gnu(); + header.set_size(contents.len() as u64); + header.set_mode(0o644); + header.set_cksum(); + tar.append_data(&mut header, path, Cursor::new(contents))?; + } + tar.finish()?; + } + + uv_snapshot!(context.filters(), context + .pip_install() + .arg(source_dist.path()), @r###" + success: false + exit_code: 1 + ----- stdout ----- + + ----- stderr ----- + Resolved 1 package in [TIME] + × Failed to build `foo @ file://[TEMP_DIR]/foo-1.2.3.tar.gz` + ╰─▶ Package metadata version `10.11.12` does not match given version `1.2.3` + "### + ); + + Ok(()) +}