mirror of https://github.com/astral-sh/uv
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:
parent
d283fff153
commit
c314c68bff
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<Version>,
|
||||
/// 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,
|
||||
})))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1922,7 +1922,7 @@ impl<'a, T: BuildContext> SourceDistributionBuilder<'a, T> {
|
|||
subdirectory: Option<&Path>,
|
||||
) -> Result<Option<ResolutionMetadata>, 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<ResolutionMetadata, Error> {
|
||||
// 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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -151,8 +151,11 @@ impl ResolutionMetadata {
|
|||
})
|
||||
}
|
||||
|
||||
pub fn parse_pyproject_toml(toml: &str) -> Result<Self, MetadataError> {
|
||||
parse_pyproject_toml(toml)
|
||||
pub fn parse_pyproject_toml(
|
||||
toml: &str,
|
||||
sdist_version: Option<&Version>,
|
||||
) -> Result<Self, MetadataError> {
|
||||
parse_pyproject_toml(toml, sdist_version)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<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 project = pyproject_toml
|
||||
|
|
@ -28,7 +34,11 @@ pub(crate) fn parse_pyproject_toml(contents: &str) -> Result<ResolutionMetadata,
|
|||
return Err(MetadataError::DynamicField("optional-dependencies"))
|
||||
}
|
||||
"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 version = project
|
||||
.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"))?;
|
||||
|
||||
// Parse the Python version requirements.
|
||||
|
|
@ -238,7 +251,7 @@ mod tests {
|
|||
[project]
|
||||
name = "asdf"
|
||||
"#;
|
||||
let meta = parse_pyproject_toml(s);
|
||||
let meta = parse_pyproject_toml(s, None);
|
||||
assert!(matches!(meta, Err(MetadataError::FieldNotFound("version"))));
|
||||
|
||||
let s = r#"
|
||||
|
|
@ -246,7 +259,7 @@ mod tests {
|
|||
name = "asdf"
|
||||
dynamic = ["version"]
|
||||
"#;
|
||||
let meta = parse_pyproject_toml(s);
|
||||
let meta = parse_pyproject_toml(s, None);
|
||||
assert!(matches!(meta, Err(MetadataError::DynamicField("version"))));
|
||||
|
||||
let s = r#"
|
||||
|
|
@ -254,7 +267,7 @@ mod tests {
|
|||
name = "asdf"
|
||||
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.version, Version::new([1, 0]));
|
||||
assert!(meta.requires_python.is_none());
|
||||
|
|
@ -267,7 +280,7 @@ mod tests {
|
|||
version = "1.0"
|
||||
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.version, Version::new([1, 0]));
|
||||
assert_eq!(meta.requires_python, Some(">=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()));
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(())
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue