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> {
|
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,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
})))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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()));
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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(())
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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(())
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue