Ignore dynamic version in source dist (#9549)

When encountering `dynamic = ["version"]` in the pyproject.toml of a
source dist, we can ignore that and treat it as a statically known
metadata distribution, since the filename tells us the version and that
version must not change on build.

This fixed locking PyGObject 3.50.0 from `pygobject-3.50.0.tar.gz`
(minimized):

```toml
[project]
name = "PyGObject"
description = "Python bindings for GObject Introspection"
requires-python = ">=3.9, <4.0"
dependencies = [
    "pycairo>=1.16"
]
dynamic = ["version"]
```

Afterwards, `uv add --no-sync toga` passes on Ubuntu 24.04 without the
pygobject build deps, when previously it needed `{ name = "pygobject",
version = "3.50.0", requires-dist = [], requires-python = ">=3.9" }`.

I've added a check that source distribution versions are respected after
build.

Fixes #9548
This commit is contained in:
konsti 2024-12-04 12:40:31 +01:00 committed by GitHub
parent d283fff153
commit c314c68bff
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 185 additions and 20 deletions

View File

@ -36,6 +36,7 @@ impl BuildableSource<'_> {
pub fn version(&self) -> Option<&Version> { pub fn version(&self) -> Option<&Version> {
match self { match self {
Self::Dist(SourceDist::Registry(dist)) => Some(&dist.version), Self::Dist(SourceDist::Registry(dist)) => Some(&dist.version),
Self::Dist(SourceDist::Path(dist)) => dist.version.as_ref(),
Self::Dist(_) => None, Self::Dist(_) => None,
Self::Url(_) => None, Self::Url(_) => None,
} }

View File

@ -39,7 +39,9 @@ use std::str::FromStr;
use url::Url; 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_fs::normalize_absolute_path;
use uv_git::GitUrl; use uv_git::GitUrl;
use uv_normalize::PackageName; use uv_normalize::PackageName;
@ -312,6 +314,7 @@ pub struct GitSourceDist {
#[derive(Debug, Clone, Hash, PartialEq, Eq)] #[derive(Debug, Clone, Hash, PartialEq, Eq)]
pub struct PathSourceDist { pub struct PathSourceDist {
pub name: PackageName, pub name: PackageName,
pub version: Option<Version>,
/// The absolute path to the distribution which we use for installing. /// The absolute path to the distribution which we use for installing.
pub install_path: PathBuf, pub install_path: PathBuf,
/// The file extension, e.g. `tar.gz`, `zip`, etc. /// The file extension, e.g. `tar.gz`, `zip`, etc.
@ -410,12 +413,24 @@ impl Dist {
url, url,
}))) })))
} }
DistExtension::Source(ext) => Ok(Self::Source(SourceDist::Path(PathSourceDist { DistExtension::Source(ext) => {
name, // If there is a version in the filename, record it.
install_path, let version = url
ext, .filename()
url, .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,
})))
}
} }
} }

View File

@ -1922,7 +1922,7 @@ impl<'a, T: BuildContext> SourceDistributionBuilder<'a, T> {
subdirectory: Option<&Path>, subdirectory: Option<&Path>,
) -> Result<Option<ResolutionMetadata>, Error> { ) -> Result<Option<ResolutionMetadata>, Error> {
// Attempt to read static metadata from the `pyproject.toml`. // 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) => { Ok(metadata) => {
debug!("Found static `pyproject.toml` for: {source}"); debug!("Found static `pyproject.toml` for: {source}");
@ -2345,6 +2345,7 @@ async fn read_pkg_info(
async fn read_pyproject_toml( async fn read_pyproject_toml(
source_tree: &Path, source_tree: &Path,
subdirectory: Option<&Path>, subdirectory: Option<&Path>,
sdist_version: Option<&Version>,
) -> Result<ResolutionMetadata, Error> { ) -> Result<ResolutionMetadata, Error> {
// Read the `pyproject.toml` file. // Read the `pyproject.toml` file.
let pyproject_toml = match subdirectory { let pyproject_toml = match subdirectory {
@ -2360,8 +2361,8 @@ async fn read_pyproject_toml(
}; };
// Parse the metadata. // Parse the metadata.
let metadata = let metadata = ResolutionMetadata::parse_pyproject_toml(&content, sdist_version)
ResolutionMetadata::parse_pyproject_toml(&content).map_err(Error::PyprojectToml)?; .map_err(Error::PyprojectToml)?;
Ok(metadata) Ok(metadata)
} }

View File

@ -151,8 +151,11 @@ impl ResolutionMetadata {
}) })
} }
pub fn parse_pyproject_toml(toml: &str) -> Result<Self, MetadataError> { pub fn parse_pyproject_toml(
parse_pyproject_toml(toml) toml: &str,
sdist_version: Option<&Version>,
) -> Result<Self, MetadataError> {
parse_pyproject_toml(toml, sdist_version)
} }
} }

View File

@ -12,7 +12,13 @@ use uv_pep440::{Version, VersionSpecifiers};
use uv_pep508::Requirement; use uv_pep508::Requirement;
/// Extract the metadata from a `pyproject.toml` file, as specified in PEP 621. /// Extract the metadata from a `pyproject.toml` file, as specified in PEP 621.
pub(crate) fn parse_pyproject_toml(contents: &str) -> Result<ResolutionMetadata, MetadataError> { ///
/// 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<ResolutionMetadata, MetadataError> {
let pyproject_toml = PyProjectToml::from_toml(contents)?; let pyproject_toml = PyProjectToml::from_toml(contents)?;
let project = pyproject_toml let project = pyproject_toml
@ -28,7 +34,11 @@ pub(crate) fn parse_pyproject_toml(contents: &str) -> Result<ResolutionMetadata,
return Err(MetadataError::DynamicField("optional-dependencies")) return Err(MetadataError::DynamicField("optional-dependencies"))
} }
"requires-python" => return Err(MetadataError::DynamicField("requires-python")), "requires-python" => 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<ResolutionMetadata,
let name = project.name; let name = project.name;
let version = project let version = project
.version .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.
.or_else(|| sdist_version.cloned())
.ok_or(MetadataError::FieldNotFound("version"))?; .ok_or(MetadataError::FieldNotFound("version"))?;
// Parse the Python version requirements. // Parse the Python version requirements.
@ -238,7 +251,7 @@ mod tests {
[project] [project]
name = "asdf" name = "asdf"
"#; "#;
let meta = parse_pyproject_toml(s); let meta = parse_pyproject_toml(s, None);
assert!(matches!(meta, Err(MetadataError::FieldNotFound("version")))); assert!(matches!(meta, Err(MetadataError::FieldNotFound("version"))));
let s = r#" let s = r#"
@ -246,7 +259,7 @@ mod tests {
name = "asdf" name = "asdf"
dynamic = ["version"] dynamic = ["version"]
"#; "#;
let meta = parse_pyproject_toml(s); let meta = parse_pyproject_toml(s, None);
assert!(matches!(meta, Err(MetadataError::DynamicField("version")))); assert!(matches!(meta, Err(MetadataError::DynamicField("version"))));
let s = r#" let s = r#"
@ -254,7 +267,7 @@ mod tests {
name = "asdf" name = "asdf"
version = "1.0" version = "1.0"
"#; "#;
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.name, PackageName::from_str("asdf").unwrap());
assert_eq!(meta.version, Version::new([1, 0])); assert_eq!(meta.version, Version::new([1, 0]));
assert!(meta.requires_python.is_none()); assert!(meta.requires_python.is_none());
@ -267,7 +280,7 @@ mod tests {
version = "1.0" version = "1.0"
requires-python = ">=3.6" requires-python = ">=3.6"
"#; "#;
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.name, PackageName::from_str("asdf").unwrap());
assert_eq!(meta.version, Version::new([1, 0])); assert_eq!(meta.version, Version::new([1, 0]));
assert_eq!(meta.requires_python, Some(">=3.6".parse().unwrap())); assert_eq!(meta.requires_python, Some(">=3.6".parse().unwrap()));
@ -281,7 +294,7 @@ mod tests {
requires-python = ">=3.6" requires-python = ">=3.6"
dependencies = ["foo"] 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.name, PackageName::from_str("asdf").unwrap());
assert_eq!(meta.version, Version::new([1, 0])); assert_eq!(meta.version, Version::new([1, 0]));
assert_eq!(meta.requires_python, Some(">=3.6".parse().unwrap())); assert_eq!(meta.requires_python, Some(">=3.6".parse().unwrap()));
@ -298,7 +311,7 @@ mod tests {
[project.optional-dependencies] [project.optional-dependencies]
dotenv = ["bar"] 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.name, PackageName::from_str("asdf").unwrap());
assert_eq!(meta.version, Version::new([1, 0])); assert_eq!(meta.version, Version::new([1, 0]));
assert_eq!(meta.requires_python, Some(">=3.6".parse().unwrap())); assert_eq!(meta.requires_python, Some(">=3.6".parse().unwrap()));

View File

@ -1784,6 +1784,7 @@ impl Package {
}; };
let path_dist = PathSourceDist { let path_dist = PathSourceDist {
name: self.id.name.clone(), name: self.id.name.clone(),
version: Some(self.id.version.clone()),
url: verbatim_url(workspace_root.join(path), &self.id)?, url: verbatim_url(workspace_root.join(path), &self.id)?,
install_path: workspace_root.join(path), install_path: workspace_root.join(path),
ext, ext,

View File

@ -2,10 +2,13 @@
use std::env::current_dir; use std::env::current_dir;
use std::fs; use std::fs;
use std::io::Cursor;
use std::path::PathBuf; use std::path::PathBuf;
use anyhow::{bail, Context, Result}; use anyhow::{bail, Context, Result};
use assert_fs::prelude::*; use assert_fs::prelude::*;
use flate2::write::GzEncoder;
use fs_err::File;
use indoc::indoc; use indoc::indoc;
use url::Url; use url::Url;
@ -13858,3 +13861,70 @@ fn compile_lowest_extra_unpinned_warning() -> Result<()> {
Ok(()) 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(())
}

View File

@ -1,9 +1,12 @@
use std::io::Cursor;
use std::process::Command; use std::process::Command;
use anyhow::Result; use anyhow::Result;
use assert_cmd::prelude::*; use assert_cmd::prelude::*;
use assert_fs::prelude::*; use assert_fs::prelude::*;
use flate2::write::GzEncoder;
use fs_err as fs; use fs_err as fs;
use fs_err::File;
use indoc::indoc; use indoc::indoc;
use predicates::prelude::predicate; use predicates::prelude::predicate;
use url::Url; use url::Url;
@ -7455,3 +7458,61 @@ fn respect_no_installer_metadata_env_var() {
.join("INSTALLER"); .join("INSTALLER");
assert!(!installer_file.exists()); 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(())
}