Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions crates/uv-distribution-types/src/buildable.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
Expand Down
29 changes: 22 additions & 7 deletions crates/uv-distribution-types/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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,
})))
}
}
}

Expand Down
7 changes: 4 additions & 3 deletions crates/uv-distribution/src/source/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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}");

Expand Down Expand Up @@ -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 {
Expand All @@ -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)
}
Expand Down
7 changes: 5 additions & 2 deletions crates/uv-pypi-types/src/metadata/metadata_resolver.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}

Expand Down
29 changes: 21 additions & 8 deletions crates/uv-pypi-types/src/metadata/pyproject_toml.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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"))
}
_ => (),
}
}
Expand All @@ -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.
Expand Down Expand Up @@ -238,23 +251,23 @@ 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#"
[project]
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#"
[project]
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());
Expand All @@ -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()));
Expand All @@ -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()));
Expand All @@ -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()));
Expand Down
1 change: 1 addition & 0 deletions crates/uv-resolver/src/lock/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
70 changes: 70 additions & 0 deletions crates/uv/tests/it/pip_compile.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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(())
}
61 changes: 61 additions & 0 deletions crates/uv/tests/it/pip_install.rs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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(())
}