diff --git a/crates/uv-distribution-types/src/dependency_metadata.rs b/crates/uv-distribution-types/src/dependency_metadata.rs index 5d5b69ffd51d..70d7b40350d5 100644 --- a/crates/uv-distribution-types/src/dependency_metadata.rs +++ b/crates/uv-distribution-types/src/dependency_metadata.rs @@ -51,6 +51,7 @@ impl DependencyMetadata { requires_dist: metadata.requires_dist.clone(), requires_python: metadata.requires_python.clone(), provides_extras: metadata.provides_extras.clone(), + dynamic: false, }) } else { // If no version was requested (i.e., it's a direct URL dependency), allow a single @@ -70,6 +71,7 @@ impl DependencyMetadata { requires_dist: metadata.requires_dist.clone(), requires_python: metadata.requires_python.clone(), provides_extras: metadata.provides_extras.clone(), + dynamic: false, }) } } diff --git a/crates/uv-distribution/src/metadata/mod.rs b/crates/uv-distribution/src/metadata/mod.rs index f5dac8830f3b..f336c719d293 100644 --- a/crates/uv-distribution/src/metadata/mod.rs +++ b/crates/uv-distribution/src/metadata/mod.rs @@ -50,6 +50,7 @@ pub struct Metadata { pub requires_python: Option, pub provides_extras: Vec, pub dependency_groups: BTreeMap>, + pub dynamic: bool, } impl Metadata { @@ -67,6 +68,7 @@ impl Metadata { requires_python: metadata.requires_python, provides_extras: metadata.provides_extras, dependency_groups: BTreeMap::default(), + dynamic: metadata.dynamic, } } @@ -109,6 +111,7 @@ impl Metadata { requires_python: metadata.requires_python, provides_extras, dependency_groups, + dynamic: metadata.dynamic, }) } } diff --git a/crates/uv-distribution/src/source/mod.rs b/crates/uv-distribution/src/source/mod.rs index 95d3f89d14bb..abdd246f20fa 100644 --- a/crates/uv-distribution/src/source/mod.rs +++ b/crates/uv-distribution/src/source/mod.rs @@ -535,14 +535,17 @@ impl<'a, T: BuildContext> SourceDistributionBuilder<'a, T> { } // If the metadata is static, return it. - if let Some(metadata) = - Self::read_static_metadata(source, source_dist_entry.path(), subdirectory).await? - { - return Ok(ArchiveMetadata { - metadata: Metadata::from_metadata23(metadata), - hashes: revision.into_hashes(), - }); - } + let dynamic = + match StaticMetadata::read(source, source_dist_entry.path(), subdirectory).await? { + StaticMetadata::Some(metadata) => { + return Ok(ArchiveMetadata { + metadata: Metadata::from_metadata23(metadata), + hashes: revision.into_hashes(), + }); + } + StaticMetadata::Dynamic => true, + StaticMetadata::None => false, + }; // If the cache contains compatible metadata, return it. let metadata_entry = cache_shard.entry(METADATA); @@ -593,6 +596,16 @@ impl<'a, T: BuildContext> SourceDistributionBuilder<'a, T> { .boxed_local() .await? { + // If necessary, mark the metadata as dynamic. + let metadata = if dynamic { + ResolutionMetadata { + dynamic: true, + ..metadata + } + } else { + metadata + }; + // Store the metadata. fs::create_dir_all(metadata_entry.dir()) .await @@ -623,17 +636,27 @@ impl<'a, T: BuildContext> SourceDistributionBuilder<'a, T> { ) .await?; - // Store the metadata. - write_atomic(metadata_entry.path(), rmp_serde::to_vec(&metadata)?) - .await - .map_err(Error::CacheWrite)?; - if let Some(task) = task { if let Some(reporter) = self.reporter.as_ref() { reporter.on_build_complete(source, task); } } + // If necessary, mark the metadata as dynamic. + let metadata = if dynamic { + ResolutionMetadata { + dynamic: true, + ..metadata + } + } else { + metadata + }; + + // Store the metadata. + write_atomic(metadata_entry.path(), rmp_serde::to_vec(&metadata)?) + .await + .map_err(Error::CacheWrite)?; + Ok(ArchiveMetadata { metadata: Metadata::from_metadata23(metadata), hashes: revision.into_hashes(), @@ -844,14 +867,16 @@ impl<'a, T: BuildContext> SourceDistributionBuilder<'a, T> { let source_entry = cache_shard.entry(SOURCE); // If the metadata is static, return it. - if let Some(metadata) = - Self::read_static_metadata(source, source_entry.path(), None).await? - { - return Ok(ArchiveMetadata { - metadata: Metadata::from_metadata23(metadata), - hashes: revision.into_hashes(), - }); - } + let dynamic = match StaticMetadata::read(source, source_entry.path(), None).await? { + StaticMetadata::Some(metadata) => { + return Ok(ArchiveMetadata { + metadata: Metadata::from_metadata23(metadata), + hashes: revision.into_hashes(), + }); + } + StaticMetadata::Dynamic => true, + StaticMetadata::None => false, + }; // If the cache contains compatible metadata, return it. let metadata_entry = cache_shard.entry(METADATA); @@ -880,6 +905,16 @@ impl<'a, T: BuildContext> SourceDistributionBuilder<'a, T> { .boxed_local() .await? { + // If necessary, mark the metadata as dynamic. + let metadata = if dynamic { + ResolutionMetadata { + dynamic: true, + ..metadata + } + } else { + metadata + }; + // Store the metadata. fs::create_dir_all(metadata_entry.dir()) .await @@ -924,6 +959,16 @@ impl<'a, T: BuildContext> SourceDistributionBuilder<'a, T> { } } + // If necessary, mark the metadata as dynamic. + let metadata = if dynamic { + ResolutionMetadata { + dynamic: true, + ..metadata + } + } else { + metadata + }; + // Store the metadata. write_atomic(metadata_entry.path(), rmp_serde::to_vec(&metadata)?) .await @@ -1093,21 +1138,24 @@ impl<'a, T: BuildContext> SourceDistributionBuilder<'a, T> { return Err(Error::HashesNotSupportedSourceTree(source.to_string())); } - if let Some(metadata) = - Self::read_static_metadata(source, &resource.install_path, None).await? - { - return Ok(ArchiveMetadata::from( - Metadata::from_workspace( - metadata, - resource.install_path.as_ref(), - None, - self.build_context.locations(), - self.build_context.sources(), - self.build_context.bounds(), - ) - .await?, - )); - } + // If the metadata is static, return it. + let dynamic = match StaticMetadata::read(source, &resource.install_path, None).await? { + StaticMetadata::Some(metadata) => { + return Ok(ArchiveMetadata::from( + Metadata::from_workspace( + metadata, + resource.install_path.as_ref(), + None, + self.build_context.locations(), + self.build_context.sources(), + self.build_context.bounds(), + ) + .await?, + )); + } + StaticMetadata::Dynamic => true, + StaticMetadata::None => false, + }; let cache_shard = self.build_context.cache().shard( CacheBucket::SourceDistributions, @@ -1160,6 +1208,16 @@ impl<'a, T: BuildContext> SourceDistributionBuilder<'a, T> { .boxed_local() .await? { + // If necessary, mark the metadata as dynamic. + let metadata = if dynamic { + ResolutionMetadata { + dynamic: true, + ..metadata + } + } else { + metadata + }; + // Store the metadata. fs::create_dir_all(metadata_entry.dir()) .await @@ -1211,6 +1269,16 @@ impl<'a, T: BuildContext> SourceDistributionBuilder<'a, T> { } } + // If necessary, mark the metadata as dynamic. + let metadata = if dynamic { + ResolutionMetadata { + dynamic: true, + ..metadata + } + } else { + metadata + }; + // Store the metadata. write_atomic(metadata_entry.path(), rmp_serde::to_vec(&metadata)?) .await @@ -1472,21 +1540,25 @@ impl<'a, T: BuildContext> SourceDistributionBuilder<'a, T> { git_source: resource, }; - if let Some(metadata) = - Self::read_static_metadata(source, fetch.path(), resource.subdirectory).await? - { - return Ok(ArchiveMetadata::from( - Metadata::from_workspace( - metadata, - &path, - Some(&git_member), - self.build_context.locations(), - self.build_context.sources(), - self.build_context.bounds(), - ) - .await?, - )); - } + // If the metadata is static, return it. + let dynamic = + match StaticMetadata::read(source, fetch.path(), resource.subdirectory).await? { + StaticMetadata::Some(metadata) => { + return Ok(ArchiveMetadata::from( + Metadata::from_workspace( + metadata, + &path, + Some(&git_member), + self.build_context.locations(), + self.build_context.sources(), + self.build_context.bounds(), + ) + .await?, + )); + } + StaticMetadata::Dynamic => true, + StaticMetadata::None => false, + }; // If the cache contains compatible metadata, return it. if self @@ -1531,6 +1603,16 @@ impl<'a, T: BuildContext> SourceDistributionBuilder<'a, T> { .boxed_local() .await? { + // If necessary, mark the metadata as dynamic. + let metadata = if dynamic { + ResolutionMetadata { + dynamic: true, + ..metadata + } + } else { + metadata + }; + // Store the metadata. fs::create_dir_all(metadata_entry.dir()) .await @@ -1582,6 +1664,16 @@ impl<'a, T: BuildContext> SourceDistributionBuilder<'a, T> { } } + // If necessary, mark the metadata as dynamic. + let metadata = if dynamic { + ResolutionMetadata { + dynamic: true, + ..metadata + } + } else { + metadata + }; + // Store the metadata. write_atomic(metadata_entry.path(), rmp_serde::to_vec(&metadata)?) .await @@ -2025,11 +2117,108 @@ impl<'a, T: BuildContext> SourceDistributionBuilder<'a, T> { Ok(Some(metadata)) } - async fn read_static_metadata( + /// Returns a GET [`reqwest::Request`] for the given URL. + fn request(url: Url, client: &RegistryClient) -> Result { + client + .uncached_client(&url) + .get(url) + .header( + // `reqwest` defaults to accepting compressed responses. + // Specify identity encoding to get consistent .whl downloading + // behavior from servers. ref: https://github.com/pypa/pip/pull/1688 + "accept-encoding", + reqwest::header::HeaderValue::from_static("identity"), + ) + .build() + } +} + +/// Prune any unused source distributions from the cache. +pub fn prune(cache: &Cache) -> Result { + let mut removal = Removal::default(); + + let bucket = cache.bucket(CacheBucket::SourceDistributions); + if bucket.is_dir() { + for entry in walkdir::WalkDir::new(bucket) { + let entry = entry.map_err(Error::CacheWalk)?; + + if !entry.file_type().is_dir() { + continue; + } + + // If we find a `revision.http` file, read the pointer, and remove any extraneous + // directories. + let revision = entry.path().join("revision.http"); + if revision.is_file() { + if let Ok(Some(pointer)) = HttpRevisionPointer::read_from(revision) { + // Remove all sibling directories that are not referenced by the pointer. + for sibling in entry.path().read_dir().map_err(Error::CacheRead)? { + let sibling = sibling.map_err(Error::CacheRead)?; + if sibling.file_type().map_err(Error::CacheRead)?.is_dir() { + let sibling_name = sibling.file_name(); + if sibling_name != pointer.revision.id().as_str() { + debug!( + "Removing dangling source revision: {}", + sibling.path().display() + ); + removal += + uv_cache::rm_rf(sibling.path()).map_err(Error::CacheWrite)?; + } + } + } + } + + continue; + } + + // If we find a `revision.rev` file, read the pointer, and remove any extraneous + // directories. + let revision = entry.path().join("revision.rev"); + if revision.is_file() { + if let Ok(Some(pointer)) = LocalRevisionPointer::read_from(revision) { + // Remove all sibling directories that are not referenced by the pointer. + for sibling in entry.path().read_dir().map_err(Error::CacheRead)? { + let sibling = sibling.map_err(Error::CacheRead)?; + if sibling.file_type().map_err(Error::CacheRead)?.is_dir() { + let sibling_name = sibling.file_name(); + if sibling_name != pointer.revision.id().as_str() { + debug!( + "Removing dangling source revision: {}", + sibling.path().display() + ); + removal += + uv_cache::rm_rf(sibling.path()).map_err(Error::CacheWrite)?; + } + } + } + } + + continue; + } + } + } + + Ok(removal) +} + +/// The result of extracting statically available metadata from a source distribution. +#[derive(Debug)] +enum StaticMetadata { + /// The metadata was found and successfully read. + Some(ResolutionMetadata), + /// The metadata was found, but it was ignored due to a dynamic version. + Dynamic, + /// The metadata was not found. + None, +} + +impl StaticMetadata { + /// Read the [`ResolutionMetadata`] from a source distribution. + async fn read( source: &BuildableSource<'_>, source_root: &Path, subdirectory: Option<&Path>, - ) -> Result, Error> { + ) -> Result { // Attempt to read static metadata from the `pyproject.toml`. match read_pyproject_toml(source_root, subdirectory, source.version()).await { Ok(metadata) => { @@ -2038,7 +2227,7 @@ impl<'a, T: BuildContext> SourceDistributionBuilder<'a, T> { // Validate the metadata, but ignore it if the metadata doesn't match. match validate_metadata(source, &metadata) { Ok(()) => { - return Ok(Some(metadata)); + return Ok(Self::Some(metadata)); } Err(Error::WheelMetadataNameMismatch { metadata, given }) => { debug!( @@ -2048,6 +2237,16 @@ impl<'a, T: BuildContext> SourceDistributionBuilder<'a, T> { Err(err) => return Err(err), } } + Err( + err @ Error::PyprojectToml(uv_pypi_types::MetadataError::DynamicField("version")), + ) if source.is_source_tree() => { + // In Metadata 2.2, `Dynamic` was introduced to Core Metadata to indicate that a + // given field was marked as dynamic in the originating source tree. However, we may + // be looking at a distribution with a build backend that doesn't support Metadata 2.2. In that case, + // we want to infer the `Dynamic` status from the `pyproject.toml` file, if available. + debug!("No static `pyproject.toml` available for: {source} ({err:?})"); + return Ok(Self::Dynamic); + } Err( err @ (Error::MissingPyprojectToml | Error::PyprojectToml( @@ -2065,7 +2264,7 @@ impl<'a, T: BuildContext> SourceDistributionBuilder<'a, T> { // If the source distribution is a source tree, avoid reading `PKG-INFO` or `egg-info`, // since they could be out-of-date. if source.is_source_tree() { - return Ok(None); + return Ok(Self::None); } // Attempt to read static metadata from the `PKG-INFO` file. @@ -2076,7 +2275,7 @@ impl<'a, T: BuildContext> SourceDistributionBuilder<'a, T> { // Validate the metadata, but ignore it if the metadata doesn't match. match validate_metadata(source, &metadata) { Ok(()) => { - return Ok(Some(metadata)); + return Ok(Self::Some(metadata)); } Err(Error::WheelMetadataNameMismatch { metadata, given }) => { debug!( @@ -2108,7 +2307,7 @@ impl<'a, T: BuildContext> SourceDistributionBuilder<'a, T> { // Validate the metadata, but ignore it if the metadata doesn't match. match validate_metadata(source, &metadata) { Ok(()) => { - return Ok(Some(metadata)); + return Ok(Self::Some(metadata)); } Err(Error::WheelMetadataNameMismatch { metadata, given }) => { debug!( @@ -2138,93 +2337,10 @@ impl<'a, T: BuildContext> SourceDistributionBuilder<'a, T> { Err(err) => return Err(err), } - Ok(None) - } - - /// Returns a GET [`reqwest::Request`] for the given URL. - fn request(url: Url, client: &RegistryClient) -> Result { - client - .uncached_client(&url) - .get(url) - .header( - // `reqwest` defaults to accepting compressed responses. - // Specify identity encoding to get consistent .whl downloading - // behavior from servers. ref: https://github.com/pypa/pip/pull/1688 - "accept-encoding", - reqwest::header::HeaderValue::from_static("identity"), - ) - .build() + Ok(Self::None) } } -/// Prune any unused source distributions from the cache. -pub fn prune(cache: &Cache) -> Result { - let mut removal = Removal::default(); - - let bucket = cache.bucket(CacheBucket::SourceDistributions); - if bucket.is_dir() { - for entry in walkdir::WalkDir::new(bucket) { - let entry = entry.map_err(Error::CacheWalk)?; - - if !entry.file_type().is_dir() { - continue; - } - - // If we find a `revision.http` file, read the pointer, and remove any extraneous - // directories. - let revision = entry.path().join("revision.http"); - if revision.is_file() { - if let Ok(Some(pointer)) = HttpRevisionPointer::read_from(revision) { - // Remove all sibling directories that are not referenced by the pointer. - for sibling in entry.path().read_dir().map_err(Error::CacheRead)? { - let sibling = sibling.map_err(Error::CacheRead)?; - if sibling.file_type().map_err(Error::CacheRead)?.is_dir() { - let sibling_name = sibling.file_name(); - if sibling_name != pointer.revision.id().as_str() { - debug!( - "Removing dangling source revision: {}", - sibling.path().display() - ); - removal += - uv_cache::rm_rf(sibling.path()).map_err(Error::CacheWrite)?; - } - } - } - } - - continue; - } - - // If we find a `revision.rev` file, read the pointer, and remove any extraneous - // directories. - let revision = entry.path().join("revision.rev"); - if revision.is_file() { - if let Ok(Some(pointer)) = LocalRevisionPointer::read_from(revision) { - // Remove all sibling directories that are not referenced by the pointer. - for sibling in entry.path().read_dir().map_err(Error::CacheRead)? { - let sibling = sibling.map_err(Error::CacheRead)?; - if sibling.file_type().map_err(Error::CacheRead)?.is_dir() { - let sibling_name = sibling.file_name(); - if sibling_name != pointer.revision.id().as_str() { - debug!( - "Removing dangling source revision: {}", - sibling.path().display() - ); - removal += - uv_cache::rm_rf(sibling.path()).map_err(Error::CacheWrite)?; - } - } - } - } - - continue; - } - } - } - - Ok(removal) -} - /// Validate that the source distribution matches the built metadata. fn validate_metadata( source: &BuildableSource<'_>, @@ -2464,6 +2580,9 @@ async fn read_egg_info( // Parse the metadata. let metadata = Metadata12::parse_metadata(&content).map_err(Error::PkgInfo)?; + // Determine whether the version is dynamic. + let dynamic = metadata.dynamic.iter().any(|field| field == "version"); + // Combine the sources. Ok(ResolutionMetadata { name: metadata.name, @@ -2471,6 +2590,7 @@ async fn read_egg_info( requires_python: metadata.requires_python, requires_dist: requires_txt.requires_dist, provides_extras: requires_txt.provides_extras, + dynamic, }) } diff --git a/crates/uv-pypi-types/src/metadata/metadata12.rs b/crates/uv-pypi-types/src/metadata/metadata12.rs index 90998dd59bcc..ef2b2038d374 100644 --- a/crates/uv-pypi-types/src/metadata/metadata12.rs +++ b/crates/uv-pypi-types/src/metadata/metadata12.rs @@ -6,7 +6,8 @@ use uv_normalize::PackageName; use uv_pep440::{Version, VersionSpecifiers}; /// A subset of the full cure metadata specification, only including the -/// fields that have been consistent across all versions of the specification later than 1.2. +/// fields that have been consistent across all versions of the specification later than 1.2, with +/// the exception of `Dynamic`, which is optional (but introduced in Metadata 2.2). /// /// Python Package Metadata 1.2 is specified in . #[derive(Deserialize, Debug, Clone)] @@ -15,6 +16,7 @@ pub struct Metadata12 { pub name: PackageName, pub version: Version, pub requires_python: Option, + pub dynamic: Vec, } impl Metadata12 { @@ -54,11 +56,13 @@ impl Metadata12 { .map(|requires_python| LenientVersionSpecifiers::from_str(&requires_python)) .transpose()? .map(VersionSpecifiers::from); + let dynamic = headers.get_all_values("Dynamic").collect::>(); Ok(Self { name, version, requires_python, + dynamic, }) } } diff --git a/crates/uv-pypi-types/src/metadata/metadata_resolver.rs b/crates/uv-pypi-types/src/metadata/metadata_resolver.rs index 2ca03ddf6111..b8d576ab53f5 100644 --- a/crates/uv-pypi-types/src/metadata/metadata_resolver.rs +++ b/crates/uv-pypi-types/src/metadata/metadata_resolver.rs @@ -29,6 +29,9 @@ pub struct ResolutionMetadata { pub requires_dist: Vec>, pub requires_python: Option, pub provides_extras: Vec, + /// Whether the version field is dynamic. + #[serde(default)] + pub dynamic: bool, } /// From @@ -68,6 +71,9 @@ impl ResolutionMetadata { } }) .collect::>(); + let dynamic = headers + .get_all_values("Dynamic") + .any(|field| field == "Version"); Ok(Self { name, @@ -75,6 +81,7 @@ impl ResolutionMetadata { requires_dist, requires_python, provides_extras, + dynamic, }) } @@ -97,12 +104,13 @@ impl ResolutionMetadata { } // If any of the fields we need are marked as dynamic, we can't use the `PKG-INFO` file. - let dynamic = headers.get_all_values("Dynamic").collect::>(); - for field in dynamic { + let mut dynamic = false; + for field in headers.get_all_values("Dynamic") { match field.as_str() { "Requires-Python" => return Err(MetadataError::DynamicField("Requires-Python")), "Requires-Dist" => return Err(MetadataError::DynamicField("Requires-Dist")), "Provides-Extra" => return Err(MetadataError::DynamicField("Provides-Extra")), + "Version" => dynamic = true, _ => (), } } @@ -148,6 +156,7 @@ impl ResolutionMetadata { requires_dist, requires_python, provides_extras, + dynamic, }) } diff --git a/crates/uv-pypi-types/src/metadata/pyproject_toml.rs b/crates/uv-pypi-types/src/metadata/pyproject_toml.rs index 08122727c221..b51d040308db 100644 --- a/crates/uv-pypi-types/src/metadata/pyproject_toml.rs +++ b/crates/uv-pypi-types/src/metadata/pyproject_toml.rs @@ -29,8 +29,8 @@ pub(crate) fn parse_pyproject_toml( .ok_or(MetadataError::FieldNotFound("project"))?; // If any of the fields we need were declared as dynamic, we can't use the `pyproject.toml` file. - let dynamic = project.dynamic.unwrap_or_default(); - for field in dynamic { + let mut dynamic = false; + for field in project.dynamic.unwrap_or_default() { match field.as_str() { "dependencies" => return Err(MetadataError::DynamicField("dependencies")), "optional-dependencies" => { @@ -39,8 +39,11 @@ pub(crate) fn parse_pyproject_toml( "requires-python" => return Err(MetadataError::DynamicField("requires-python")), // 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")) + "version" => { + if sdist_version.is_none() { + return Err(MetadataError::DynamicField("version")); + } + dynamic = true; } _ => (), } @@ -99,6 +102,7 @@ pub(crate) fn parse_pyproject_toml( requires_dist, requires_python, provides_extras, + dynamic, }) } diff --git a/crates/uv-requirements/src/upgrade.rs b/crates/uv-requirements/src/upgrade.rs index ed6d9a747461..7e31facc3db6 100644 --- a/crates/uv-requirements/src/upgrade.rs +++ b/crates/uv-requirements/src/upgrade.rs @@ -83,7 +83,9 @@ pub fn read_lock_requirements( } // Map each entry in the lockfile to a preference. - preferences.push(Preference::from_lock(package, install_path)?); + if let Some(preference) = Preference::from_lock(package, install_path)? { + preferences.push(preference); + } // Map each entry in the lockfile to a Git SHA. if let Some(git_ref) = package.as_git_ref()? { diff --git a/crates/uv-resolver/src/lock/installable.rs b/crates/uv-resolver/src/lock/installable.rs index 073ff67c26bf..97b59f2d0b77 100644 --- a/crates/uv-resolver/src/lock/installable.rs +++ b/crates/uv-resolver/src/lock/installable.rs @@ -344,11 +344,8 @@ pub trait Installable<'lock> { TagPolicy::Required(tags), build_options, )?; - let version = package.version().clone(); - let dist = ResolvedDist::Installable { - dist, - version: Some(version), - }; + let version = package.version().cloned(); + let dist = ResolvedDist::Installable { dist, version }; let hashes = package.hashes(); Ok(Node::Dist { dist, @@ -364,11 +361,8 @@ pub trait Installable<'lock> { TagPolicy::Preferred(tags), &BuildOptions::default(), )?; - let version = package.version().clone(); - let dist = ResolvedDist::Installable { - dist, - version: Some(version), - }; + let version = package.version().cloned(); + let dist = ResolvedDist::Installable { dist, version }; let hashes = package.hashes(); Ok(Node::Dist { dist, diff --git a/crates/uv-resolver/src/lock/mod.rs b/crates/uv-resolver/src/lock/mod.rs index d427aa74fc95..f518b73e2014 100644 --- a/crates/uv-resolver/src/lock/mod.rs +++ b/crates/uv-resolver/src/lock/mod.rs @@ -1004,31 +1004,20 @@ impl Lock { .flatten() .map(|package| matches!(package.id.source, Source::Virtual(_))); if actual != Some(expected) { - return Ok(SatisfiesResult::MismatchedSources(name.clone(), expected)); + return Ok(SatisfiesResult::MismatchedVirtual(name.clone(), expected)); } } - // E.g., that the version has changed. + // E.g., that they've switched from dynamic to non-dynamic or vice versa. for (name, member) in packages { - let Some(expected) = member - .pyproject_toml() - .project - .as_ref() - .and_then(|project| project.version.as_ref()) - else { - continue; - }; + let expected = member.pyproject_toml().is_dynamic(); let actual = self .find_by_name(name) .ok() .flatten() - .map(|package| &package.id.version); + .map(Package::is_dynamic); if actual != Some(expected) { - return Ok(SatisfiesResult::MismatchedVersion( - name.clone(), - expected.clone(), - actual.cloned(), - )); + return Ok(SatisfiesResult::MismatchedDynamic(name.clone(), expected)); } } } @@ -1196,20 +1185,24 @@ impl Lock { .as_ref() .is_some_and(|remotes| !remotes.contains(url)) { - return Ok(SatisfiesResult::MissingRemoteIndex( - &package.id.name, - &package.id.version, - url, - )); + let name = &package.id.name; + let version = &package + .id + .version + .as_ref() + .expect("version for registry source"); + return Ok(SatisfiesResult::MissingRemoteIndex(name, version, url)); } } RegistrySource::Path(path) => { if locals.as_ref().is_some_and(|locals| !locals.contains(path)) { - return Ok(SatisfiesResult::MissingLocalIndex( - &package.id.name, - &package.id.version, - path, - )); + let name = &package.id.name; + let version = &package + .id + .version + .as_ref() + .expect("version for registry source"); + return Ok(SatisfiesResult::MissingLocalIndex(name, version, path)); } } }; @@ -1233,6 +1226,9 @@ impl Lock { )?; // Fetch the metadata for the distribution. + // + // TODO(charlie): We don't need the version here, so we could avoid running a PEP 517 + // build if only the version is dynamic. let metadata = { let id = dist.version_id(); if let Some(archive) = @@ -1271,15 +1267,6 @@ impl Lock { } }; - // Validate the `version` metadata. - if metadata.version != package.id.version { - return Ok(SatisfiesResult::MismatchedVersion( - package.id.name.clone(), - package.id.version.clone(), - Some(metadata.version.clone()), - )); - } - // Validate the `requires-dist` metadata. { let expected: BTreeSet<_> = metadata @@ -1298,7 +1285,7 @@ impl Lock { if expected != actual { return Ok(SatisfiesResult::MismatchedPackageRequirements( &package.id.name, - &package.id.version, + package.id.version.as_ref(), expected, actual, )); @@ -1341,7 +1328,7 @@ impl Lock { if expected != actual { return Ok(SatisfiesResult::MismatchedPackageDependencyGroups( &package.id.name, - &package.id.version, + package.id.version.as_ref(), expected, actual, )); @@ -1406,8 +1393,10 @@ pub enum SatisfiesResult<'lock> { Satisfied, /// The lockfile uses a different set of workspace members. MismatchedMembers(BTreeSet, &'lock BTreeSet), - /// The lockfile uses a different set of sources for its workspace members. - MismatchedSources(PackageName, bool), + /// A workspace member switched from virtual to non-virtual or vice versa. + MismatchedVirtual(PackageName, bool), + /// A workspace member switched from dynamic to non-dynamic or vice versa. + MismatchedDynamic(PackageName, bool), /// The lockfile uses a different set of version for its workspace members. MismatchedVersion(PackageName, Version, Option), /// The lockfile uses a different set of requirements. @@ -1432,14 +1421,14 @@ pub enum SatisfiesResult<'lock> { /// A package in the lockfile contains different `requires-dist` metadata than expected. MismatchedPackageRequirements( &'lock PackageName, - &'lock Version, + Option<&'lock Version>, BTreeSet, BTreeSet, ), /// A package in the lockfile contains different `dependency-group` metadata than expected. MismatchedPackageDependencyGroups( &'lock PackageName, - &'lock Version, + Option<&'lock Version>, BTreeMap>, BTreeMap>, ), @@ -1953,7 +1942,7 @@ impl Package { let install_path = absolute_path(workspace_root, path)?; let path_dist = PathSourceDist { name: self.id.name.clone(), - version: Some(self.id.version.clone()), + version: self.id.version.clone(), url: verbatim_url(&install_path, &self.id)?, install_path, ext, @@ -2047,9 +2036,16 @@ impl Package { return Ok(None); }; + let name = &self.id.name; + let version = self + .id + .version + .as_ref() + .expect("version for registry source"); + let file_url = sdist.url().ok_or_else(|| LockErrorKind::MissingUrl { - name: self.id.name.clone(), - version: self.id.version.clone(), + name: name.clone(), + version: version.clone(), })?; let filename = sdist .filename() @@ -2076,8 +2072,8 @@ impl Package { )); let reg_dist = RegistrySourceDist { - name: self.id.name.clone(), - version: self.id.version.clone(), + name: name.clone(), + version: version.clone(), file, ext, index, @@ -2090,9 +2086,16 @@ impl Package { return Ok(None); }; + let name = &self.id.name; + let version = self + .id + .version + .as_ref() + .expect("version for registry source"); + let file_path = sdist.path().ok_or_else(|| LockErrorKind::MissingPath { - name: self.id.name.clone(), - version: self.id.version.clone(), + name: name.clone(), + version: version.clone(), })?; let file_url = Url::from_file_path(workspace_root.join(path).join(file_path)) .map_err(|()| LockErrorKind::PathToUrl)?; @@ -2121,8 +2124,8 @@ impl Package { ); let reg_dist = RegistrySourceDist { - name: self.id.name.clone(), - version: self.id.version.clone(), + name: name.clone(), + version: version.clone(), file, ext, index, @@ -2302,8 +2305,8 @@ impl Package { } /// Returns the [`Version`] of the package. - pub fn version(&self) -> &Version { - &self.id.version + pub fn version(&self) -> Option<&Version> { + self.id.version.as_ref() } /// Return the fork markers for this package, if any. @@ -2358,6 +2361,11 @@ impl Package { _ => Ok(None), } } + + /// Returns `true` if the package is a dynamic source tree. + fn is_dynamic(&self) -> bool { + self.id.version.is_none() + } } /// Attempts to construct a `VerbatimUrl` from the given `Path`. @@ -2452,7 +2460,7 @@ impl PackageWire { #[derive(Clone, Debug, Eq, Hash, PartialEq, PartialOrd, Ord, serde::Deserialize)] pub(crate) struct PackageId { pub(crate) name: PackageName, - pub(crate) version: Version, + pub(crate) version: Option, source: Source, } @@ -2461,9 +2469,20 @@ impl PackageId { annotated_dist: &AnnotatedDist, root: &Path, ) -> Result { - let name = annotated_dist.name.clone(); - let version = annotated_dist.version.clone(); + // Identify the source of the package. let source = Source::from_resolved_dist(&annotated_dist.dist, root)?; + // Omit versions for dynamic source trees. + let version = if source.is_source_tree() + && annotated_dist + .metadata + .as_ref() + .is_some_and(|metadata| metadata.dynamic) + { + None + } else { + Some(annotated_dist.version.clone()) + }; + let name = annotated_dist.name.clone(); Ok(Self { name, version, @@ -2481,7 +2500,9 @@ impl PackageId { let count = dist_count_by_name.and_then(|map| map.get(&self.name).copied()); table.insert("name", value(self.name.to_string())); if count.map(|count| count > 1).unwrap_or(true) { - table.insert("version", value(self.version.to_string())); + if let Some(version) = &self.version { + table.insert("version", value(version.to_string())); + } self.source.to_toml(table); } } @@ -2489,7 +2510,11 @@ impl PackageId { impl Display for PackageId { fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { - write!(f, "{}=={} @ {}", self.name, self.version, self.source) + if let Some(version) = &self.version { + write!(f, "{}=={} @ {}", self.name, version, self.source) + } else { + write!(f, "{} @ {}", self.name, self.source) + } } } @@ -2506,15 +2531,17 @@ impl PackageIdForDependency { unambiguous_package_ids: &FxHashMap, ) -> Result { let unambiguous_package_id = unambiguous_package_ids.get(&self.name); - let version = self.version.map(Ok::<_, LockError>).unwrap_or_else(|| { + let version = if let Some(version) = self.version { + Some(version) + } else { let Some(dist_id) = unambiguous_package_id else { return Err(LockErrorKind::MissingDependencyVersion { name: self.name.clone(), } .into()); }; - Ok(dist_id.version.clone()) - })?; + dist_id.version.clone() + }; let source = self.source.map(Ok::<_, LockError>).unwrap_or_else(|| { let Some(package_id) = unambiguous_package_id else { return Err(LockErrorKind::MissingDependencySource { @@ -2536,7 +2563,7 @@ impl From for PackageIdForDependency { fn from(id: PackageId) -> PackageIdForDependency { PackageIdForDependency { name: id.name, - version: Some(id.version), + version: id.version, source: Some(id.source), } } @@ -2742,6 +2769,14 @@ impl Source { } } + /// Returns `true` if the source is that of a source tree. + pub(crate) fn is_source_tree(&self) -> bool { + match self { + Source::Directory(..) | Source::Editable(..) | Source::Virtual(..) => true, + Source::Path(..) | Source::Git(..) | Source::Registry(..) | Source::Direct(..) => false, + } + } + fn to_toml(&self, table: &mut Table) { let mut source_table = InlineTable::new(); match *self { @@ -3824,21 +3859,22 @@ impl Dependency { impl Display for Dependency { fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { - if self.extra.is_empty() { - write!( - f, - "{}=={} @ {}", - self.package_id.name, self.package_id.version, self.package_id.source - ) - } else { - write!( + match (self.extra.is_empty(), self.package_id.version.as_ref()) { + (true, Some(version)) => write!(f, "{}=={}", self.package_id.name, version), + (true, None) => write!(f, "{}", self.package_id.name), + (false, Some(version)) => write!( f, - "{}[{}]=={} @ {}", + "{}[{}]=={}", self.package_id.name, self.extra.iter().join(","), - self.package_id.version, - self.package_id.source - ) + version + ), + (false, None) => write!( + f, + "{}[{}]", + self.package_id.name, + self.extra.iter().join(",") + ), } } } diff --git a/crates/uv-resolver/src/lock/requirements_txt.rs b/crates/uv-resolver/src/lock/requirements_txt.rs index 694064727e34..412143a97aa9 100644 --- a/crates/uv-resolver/src/lock/requirements_txt.rs +++ b/crates/uv-resolver/src/lock/requirements_txt.rs @@ -298,7 +298,12 @@ impl std::fmt::Display for RequirementsTxtExport<'_> { for Requirement { package, marker } in &self.nodes { match &package.id.source { Source::Registry(_) => { - write!(f, "{}=={}", package.id.name, package.id.version)?; + let version = package + .id + .version + .as_ref() + .expect("registry package without version"); + write!(f, "{}=={}", package.id.name, version)?; } Source::Git(url, git) => { // Remove the fragment and query from the URL; they're already present in the diff --git a/crates/uv-resolver/src/lock/snapshots/uv_resolver__lock__tests__hash_optional_missing.snap b/crates/uv-resolver/src/lock/snapshots/uv_resolver__lock__tests__hash_optional_missing.snap index 873835f7e53f..b0769642b738 100644 --- a/crates/uv-resolver/src/lock/snapshots/uv_resolver__lock__tests__hash_optional_missing.snap +++ b/crates/uv-resolver/src/lock/snapshots/uv_resolver__lock__tests__hash_optional_missing.snap @@ -42,7 +42,9 @@ Ok( name: PackageName( "anyio", ), - version: "4.3.0", + version: Some( + "4.3.0", + ), source: Registry( Url( UrlString( @@ -94,7 +96,9 @@ Ok( name: PackageName( "anyio", ), - version: "4.3.0", + version: Some( + "4.3.0", + ), source: Registry( Url( UrlString( diff --git a/crates/uv-resolver/src/lock/snapshots/uv_resolver__lock__tests__hash_optional_present.snap b/crates/uv-resolver/src/lock/snapshots/uv_resolver__lock__tests__hash_optional_present.snap index 74cea264fc32..d116e18ee946 100644 --- a/crates/uv-resolver/src/lock/snapshots/uv_resolver__lock__tests__hash_optional_present.snap +++ b/crates/uv-resolver/src/lock/snapshots/uv_resolver__lock__tests__hash_optional_present.snap @@ -42,7 +42,9 @@ Ok( name: PackageName( "anyio", ), - version: "4.3.0", + version: Some( + "4.3.0", + ), source: Registry( Url( UrlString( @@ -101,7 +103,9 @@ Ok( name: PackageName( "anyio", ), - version: "4.3.0", + version: Some( + "4.3.0", + ), source: Registry( Url( UrlString( diff --git a/crates/uv-resolver/src/lock/snapshots/uv_resolver__lock__tests__hash_required_present.snap b/crates/uv-resolver/src/lock/snapshots/uv_resolver__lock__tests__hash_required_present.snap index fce4cc5a09dc..9be0baf32f27 100644 --- a/crates/uv-resolver/src/lock/snapshots/uv_resolver__lock__tests__hash_required_present.snap +++ b/crates/uv-resolver/src/lock/snapshots/uv_resolver__lock__tests__hash_required_present.snap @@ -42,7 +42,9 @@ Ok( name: PackageName( "anyio", ), - version: "4.3.0", + version: Some( + "4.3.0", + ), source: Path( "file:///foo/bar", ), @@ -97,7 +99,9 @@ Ok( name: PackageName( "anyio", ), - version: "4.3.0", + version: Some( + "4.3.0", + ), source: Path( "file:///foo/bar", ), diff --git a/crates/uv-resolver/src/lock/snapshots/uv_resolver__lock__tests__missing_dependency_source_unambiguous.snap b/crates/uv-resolver/src/lock/snapshots/uv_resolver__lock__tests__missing_dependency_source_unambiguous.snap index f8ffdde88100..8052907601fb 100644 --- a/crates/uv-resolver/src/lock/snapshots/uv_resolver__lock__tests__missing_dependency_source_unambiguous.snap +++ b/crates/uv-resolver/src/lock/snapshots/uv_resolver__lock__tests__missing_dependency_source_unambiguous.snap @@ -42,7 +42,9 @@ Ok( name: PackageName( "a", ), - version: "0.1.0", + version: Some( + "0.1.0", + ), source: Registry( Url( UrlString( @@ -86,7 +88,9 @@ Ok( name: PackageName( "b", ), - version: "0.1.0", + version: Some( + "0.1.0", + ), source: Registry( Url( UrlString( @@ -123,7 +127,9 @@ Ok( name: PackageName( "a", ), - version: "0.1.0", + version: Some( + "0.1.0", + ), source: Registry( Url( UrlString( @@ -150,9 +156,11 @@ Ok( by_id: { PackageId { name: PackageName( - "a", + "b", + ), + version: Some( + "0.1.0", ), - version: "0.1.0", source: Registry( Url( UrlString( @@ -160,12 +168,14 @@ Ok( ), ), ), - }: 0, + }: 1, PackageId { name: PackageName( - "b", + "a", + ), + version: Some( + "0.1.0", ), - version: "0.1.0", source: Registry( Url( UrlString( @@ -173,7 +183,7 @@ Ok( ), ), ), - }: 1, + }: 0, }, manifest: ResolverManifest { members: {}, diff --git a/crates/uv-resolver/src/lock/snapshots/uv_resolver__lock__tests__missing_dependency_source_version_unambiguous.snap b/crates/uv-resolver/src/lock/snapshots/uv_resolver__lock__tests__missing_dependency_source_version_unambiguous.snap index f8ffdde88100..8052907601fb 100644 --- a/crates/uv-resolver/src/lock/snapshots/uv_resolver__lock__tests__missing_dependency_source_version_unambiguous.snap +++ b/crates/uv-resolver/src/lock/snapshots/uv_resolver__lock__tests__missing_dependency_source_version_unambiguous.snap @@ -42,7 +42,9 @@ Ok( name: PackageName( "a", ), - version: "0.1.0", + version: Some( + "0.1.0", + ), source: Registry( Url( UrlString( @@ -86,7 +88,9 @@ Ok( name: PackageName( "b", ), - version: "0.1.0", + version: Some( + "0.1.0", + ), source: Registry( Url( UrlString( @@ -123,7 +127,9 @@ Ok( name: PackageName( "a", ), - version: "0.1.0", + version: Some( + "0.1.0", + ), source: Registry( Url( UrlString( @@ -150,9 +156,11 @@ Ok( by_id: { PackageId { name: PackageName( - "a", + "b", + ), + version: Some( + "0.1.0", ), - version: "0.1.0", source: Registry( Url( UrlString( @@ -160,12 +168,14 @@ Ok( ), ), ), - }: 0, + }: 1, PackageId { name: PackageName( - "b", + "a", + ), + version: Some( + "0.1.0", ), - version: "0.1.0", source: Registry( Url( UrlString( @@ -173,7 +183,7 @@ Ok( ), ), ), - }: 1, + }: 0, }, manifest: ResolverManifest { members: {}, diff --git a/crates/uv-resolver/src/lock/snapshots/uv_resolver__lock__tests__missing_dependency_version_unambiguous.snap b/crates/uv-resolver/src/lock/snapshots/uv_resolver__lock__tests__missing_dependency_version_unambiguous.snap index f8ffdde88100..8052907601fb 100644 --- a/crates/uv-resolver/src/lock/snapshots/uv_resolver__lock__tests__missing_dependency_version_unambiguous.snap +++ b/crates/uv-resolver/src/lock/snapshots/uv_resolver__lock__tests__missing_dependency_version_unambiguous.snap @@ -42,7 +42,9 @@ Ok( name: PackageName( "a", ), - version: "0.1.0", + version: Some( + "0.1.0", + ), source: Registry( Url( UrlString( @@ -86,7 +88,9 @@ Ok( name: PackageName( "b", ), - version: "0.1.0", + version: Some( + "0.1.0", + ), source: Registry( Url( UrlString( @@ -123,7 +127,9 @@ Ok( name: PackageName( "a", ), - version: "0.1.0", + version: Some( + "0.1.0", + ), source: Registry( Url( UrlString( @@ -150,9 +156,11 @@ Ok( by_id: { PackageId { name: PackageName( - "a", + "b", + ), + version: Some( + "0.1.0", ), - version: "0.1.0", source: Registry( Url( UrlString( @@ -160,12 +168,14 @@ Ok( ), ), ), - }: 0, + }: 1, PackageId { name: PackageName( - "b", + "a", + ), + version: Some( + "0.1.0", ), - version: "0.1.0", source: Registry( Url( UrlString( @@ -173,7 +183,7 @@ Ok( ), ), ), - }: 1, + }: 0, }, manifest: ResolverManifest { members: {}, diff --git a/crates/uv-resolver/src/lock/snapshots/uv_resolver__lock__tests__source_direct_has_subdir.snap b/crates/uv-resolver/src/lock/snapshots/uv_resolver__lock__tests__source_direct_has_subdir.snap index cc44f4dd9bef..61bf7f84037a 100644 --- a/crates/uv-resolver/src/lock/snapshots/uv_resolver__lock__tests__source_direct_has_subdir.snap +++ b/crates/uv-resolver/src/lock/snapshots/uv_resolver__lock__tests__source_direct_has_subdir.snap @@ -42,7 +42,9 @@ Ok( name: PackageName( "anyio", ), - version: "4.3.0", + version: Some( + "4.3.0", + ), source: Direct( UrlString( "https://burntsushi.net", @@ -71,7 +73,9 @@ Ok( name: PackageName( "anyio", ), - version: "4.3.0", + version: Some( + "4.3.0", + ), source: Direct( UrlString( "https://burntsushi.net", diff --git a/crates/uv-resolver/src/lock/snapshots/uv_resolver__lock__tests__source_direct_no_subdir.snap b/crates/uv-resolver/src/lock/snapshots/uv_resolver__lock__tests__source_direct_no_subdir.snap index 864f07525e1f..cdf9d68dc710 100644 --- a/crates/uv-resolver/src/lock/snapshots/uv_resolver__lock__tests__source_direct_no_subdir.snap +++ b/crates/uv-resolver/src/lock/snapshots/uv_resolver__lock__tests__source_direct_no_subdir.snap @@ -42,7 +42,9 @@ Ok( name: PackageName( "anyio", ), - version: "4.3.0", + version: Some( + "4.3.0", + ), source: Direct( UrlString( "https://burntsushi.net", @@ -69,7 +71,9 @@ Ok( name: PackageName( "anyio", ), - version: "4.3.0", + version: Some( + "4.3.0", + ), source: Direct( UrlString( "https://burntsushi.net", diff --git a/crates/uv-resolver/src/lock/snapshots/uv_resolver__lock__tests__source_directory.snap b/crates/uv-resolver/src/lock/snapshots/uv_resolver__lock__tests__source_directory.snap index a03dc6747ccd..a9f2849ed9bb 100644 --- a/crates/uv-resolver/src/lock/snapshots/uv_resolver__lock__tests__source_directory.snap +++ b/crates/uv-resolver/src/lock/snapshots/uv_resolver__lock__tests__source_directory.snap @@ -42,7 +42,9 @@ Ok( name: PackageName( "anyio", ), - version: "4.3.0", + version: Some( + "4.3.0", + ), source: Directory( "path/to/dir", ), @@ -64,7 +66,9 @@ Ok( name: PackageName( "anyio", ), - version: "4.3.0", + version: Some( + "4.3.0", + ), source: Directory( "path/to/dir", ), diff --git a/crates/uv-resolver/src/lock/snapshots/uv_resolver__lock__tests__source_editable.snap b/crates/uv-resolver/src/lock/snapshots/uv_resolver__lock__tests__source_editable.snap index ea064625f069..5357b6934627 100644 --- a/crates/uv-resolver/src/lock/snapshots/uv_resolver__lock__tests__source_editable.snap +++ b/crates/uv-resolver/src/lock/snapshots/uv_resolver__lock__tests__source_editable.snap @@ -42,7 +42,9 @@ Ok( name: PackageName( "anyio", ), - version: "4.3.0", + version: Some( + "4.3.0", + ), source: Editable( "path/to/dir", ), @@ -64,7 +66,9 @@ Ok( name: PackageName( "anyio", ), - version: "4.3.0", + version: Some( + "4.3.0", + ), source: Editable( "path/to/dir", ), diff --git a/crates/uv-resolver/src/lock/tree.rs b/crates/uv-resolver/src/lock/tree.rs index 02708e6b302c..8b04e2dc29d9 100644 --- a/crates/uv-resolver/src/lock/tree.rs +++ b/crates/uv-resolver/src/lock/tree.rs @@ -369,9 +369,11 @@ impl<'env> TreeDisplay<'env> { } } - line.push(' '); - line.push('v'); - line.push_str(&format!("{}", package_id.version)); + if let Some(version) = package_id.version.as_ref() { + line.push(' '); + line.push('v'); + line.push_str(&format!("{version}")); + } if let Some(edge) = edge { match edge { diff --git a/crates/uv-resolver/src/preferences.rs b/crates/uv-resolver/src/preferences.rs index c20901fc5139..feb14ce21d98 100644 --- a/crates/uv-resolver/src/preferences.rs +++ b/crates/uv-resolver/src/preferences.rs @@ -95,15 +95,18 @@ impl Preference { pub fn from_lock( package: &crate::lock::Package, install_path: &Path, - ) -> Result { - Ok(Self { + ) -> Result, LockError> { + let Some(version) = package.version() else { + return Ok(None); + }; + Ok(Some(Self { name: package.id.name.clone(), - version: package.id.version.clone(), + version: version.clone(), marker: MarkerTree::TRUE, index: package.index(install_path)?, fork_markers: package.fork_markers().to_vec(), hashes: Vec::new(), - }) + })) } /// Return the [`PackageName`] of the package for this [`Preference`]. diff --git a/crates/uv-workspace/src/pyproject.rs b/crates/uv-workspace/src/pyproject.rs index a6ee2b2536a7..d2d19cd9b5f4 100644 --- a/crates/uv-workspace/src/pyproject.rs +++ b/crates/uv-workspace/src/pyproject.rs @@ -87,6 +87,13 @@ impl PyProjectToml { self.build_system.is_some() } + /// Returns `true` if the project uses a dynamic version. + pub fn is_dynamic(&self) -> bool { + self.project + .as_ref() + .is_some_and(|project| project.version.is_none()) + } + /// Returns whether the project manifest contains any script table. pub fn has_scripts(&self) -> bool { if let Some(ref project) = self.project { diff --git a/crates/uv/src/commands/project/add.rs b/crates/uv/src/commands/project/add.rs index 838fbbc1d0ff..7e0d12c06a96 100644 --- a/crates/uv/src/commands/project/add.rs +++ b/crates/uv/src/commands/project/add.rs @@ -690,7 +690,9 @@ async fn lock_and_sync( FxHashMap::with_capacity_and_hasher(lock.packages().len(), FxBuildHasher); for dist in lock.packages() { let name = dist.name(); - let version = dist.version(); + let Some(version) = dist.version() else { + continue; + }; match minimum_version.entry(name) { Entry::Vacant(entry) => { entry.insert(version); diff --git a/crates/uv/src/commands/project/lock.rs b/crates/uv/src/commands/project/lock.rs index e9612fe4787c..845f15a041e3 100644 --- a/crates/uv/src/commands/project/lock.rs +++ b/crates/uv/src/commands/project/lock.rs @@ -930,7 +930,7 @@ impl ValidatedLock { ); Ok(Self::Preferable(lock)) } - SatisfiesResult::MismatchedSources(name, expected) => { + SatisfiesResult::MismatchedVirtual(name, expected) => { if expected { debug!( "Ignoring existing lockfile due to mismatched source: `{name}` (expected: `virtual`)" @@ -942,6 +942,18 @@ impl ValidatedLock { } Ok(Self::Preferable(lock)) } + SatisfiesResult::MismatchedDynamic(name, expected) => { + if expected { + debug!( + "Ignoring existing lockfile due to static version: `{name}` (expected a dynamic version)" + ); + } else { + debug!( + "Ignoring existing lockfile due to dynamic version: `{name}` (expected a static version)" + ); + } + Ok(Self::Preferable(lock)) + } SatisfiesResult::MismatchedVersion(name, expected, actual) => { if let Some(actual) = actual { debug!( @@ -1006,17 +1018,31 @@ impl ValidatedLock { Ok(Self::Preferable(lock)) } SatisfiesResult::MismatchedPackageRequirements(name, version, expected, actual) => { - debug!( - "Ignoring existing lockfile due to mismatched `requires-dist` for: `{name}=={version}`\n Requested: {:?}\n Existing: {:?}", - expected, actual - ); + if let Some(version) = version { + debug!( + "Ignoring existing lockfile due to mismatched requirements for: `{name}=={version}`\n Requested: {:?}\n Existing: {:?}", + expected, actual + ); + } else { + debug!( + "Ignoring existing lockfile due to mismatched requirements for: `{name}`\n Requested: {:?}\n Existing: {:?}", + expected, actual + ); + } Ok(Self::Preferable(lock)) } SatisfiesResult::MismatchedPackageDependencyGroups(name, version, expected, actual) => { - debug!( - "Ignoring existing lockfile due to mismatched dev dependencies for: `{name}=={version}`\n Requested: {:?}\n Existing: {:?}", - expected, actual - ); + if let Some(version) = version { + debug!( + "Ignoring existing lockfile due to mismatched dependency groups for: `{name}=={version}`\n Requested: {:?}\n Existing: {:?}", + expected, actual + ); + } else { + debug!( + "Ignoring existing lockfile due to mismatched dependency groups for: `{name}`\n Requested: {:?}\n Existing: {:?}", + expected, actual + ); + } Ok(Self::Preferable(lock)) } } @@ -1048,9 +1074,9 @@ fn report_upgrades( existing_lock.packages().iter().fold( FxHashMap::with_capacity_and_hasher(existing_lock.packages().len(), FxBuildHasher), |mut acc, package| { - acc.entry(package.name()) - .or_default() - .insert(package.version()); + if let Some(version) = package.version() { + acc.entry(package.name()).or_default().insert(version); + } acc }, ) @@ -1062,9 +1088,9 @@ fn report_upgrades( new_lock.packages().iter().fold( FxHashMap::with_capacity_and_hasher(new_lock.packages().len(), FxBuildHasher), |mut acc, package| { - acc.entry(package.name()) - .or_default() - .insert(package.version()); + if let Some(version) = package.version() { + acc.entry(package.name()).or_default().insert(version); + } acc }, ); diff --git a/crates/uv/src/commands/project/tree.rs b/crates/uv/src/commands/project/tree.rs index 384eada5e634..fc9d274686f2 100644 --- a/crates/uv/src/commands/project/tree.rs +++ b/crates/uv/src/commands/project/tree.rs @@ -257,7 +257,7 @@ pub(crate) async fn tree( continue; }; reporter.on_fetch_version(package.name(), &version); - if version > *package.version() { + if package.version().is_some_and(|package| version > *package) { map.insert(package.clone(), version); } } diff --git a/crates/uv/tests/it/lock.rs b/crates/uv/tests/it/lock.rs index 0c6883fd9336..82c6f3722b43 100644 --- a/crates/uv/tests/it/lock.rs +++ b/crates/uv/tests/it/lock.rs @@ -14856,7 +14856,7 @@ fn lock_explicit_default_index() -> Result<()> { DEBUG Using request timeout of [TIME] DEBUG Found static `pyproject.toml` for: project @ file://[TEMP_DIR]/ DEBUG No workspace root found, using project root - DEBUG Ignoring existing lockfile due to mismatched `requires-dist` for: `project==0.1.0` + DEBUG Ignoring existing lockfile due to mismatched requirements for: `project==0.1.0` Requested: {Requirement { name: PackageName("anyio"), extras: [], groups: [], marker: true, source: Registry { specifier: VersionSpecifiers([]), index: None, conflict: None }, origin: None }} Existing: {Requirement { name: PackageName("iniconfig"), extras: [], groups: [], marker: true, source: Registry { specifier: VersionSpecifiers([VersionSpecifier { operator: Equal, version: "2.0.0" }]), index: Some(Url { scheme: "https", cannot_be_a_base: false, username: "", password: None, host: Some(Domain("test.pypi.org")), port: None, path: "/simple", query: None, fragment: None }), conflict: None }, origin: None }} DEBUG Solving with installed Python version: 3.12.[X] @@ -19821,7 +19821,7 @@ fn lock_transitive_git() -> Result<()> { Ok(()) } -/// Lock a package that's excluded from the parent workspace, but depends on that parent. +/// Lock a package with a dynamic version. #[test] fn lock_dynamic_version() -> Result<()> { let context = TestContext::new("3.12"); @@ -19883,7 +19883,6 @@ fn lock_dynamic_version() -> Result<()> { [[package]] name = "project" - version = "0.1.0" source = { editable = "." } "### ); @@ -19897,26 +19896,103 @@ fn lock_dynamic_version() -> Result<()> { .child("__init__.py") .write_str("__version__ = '0.1.1'")?; - // Re-run with `--locked`. + // Re-run with `--locked`. We should accept the lockfile, since dynamic versions are omitted. uv_snapshot!(context.filters(), context.lock().arg("--locked"), @r###" - success: false - exit_code: 2 + success: true + exit_code: 0 ----- stdout ----- ----- stderr ----- Resolved 1 package in [TIME] - error: The lockfile at `uv.lock` needs to be updated, but `--locked` was provided. To update the lockfile, run `uv lock`. "###); - // Re-lock. + let lock = fs_err::read_to_string(context.temp_dir.join("uv.lock")).unwrap(); + + insta::with_settings!({ + filters => context.filters(), + }, { + assert_snapshot!( + lock, @r###" + version = 1 + requires-python = ">=3.12" + + [options] + exclude-newer = "2024-03-25T00:00:00Z" + + [[package]] + name = "project" + source = { editable = "." } + "### + ); + }); + + Ok(()) +} + +/// Lock a package that depends on a package with a dynamic version using a `workspace` source. +#[test] +fn lock_dynamic_version_workspace_member() -> Result<()> { + let context = TestContext::new("3.12"); + + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str( + r#" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = ["dynamic", "iniconfig>=2"] + + [tool.uv.workspace] + members = ["dynamic"] + + [tool.uv.sources] + dynamic = { workspace = true } + "#, + )?; + + // Create a project with a dynamic version. + let pyproject_toml = context.temp_dir.child("dynamic").child("pyproject.toml"); + pyproject_toml.write_str( + r#" + [project] + name = "dynamic" + requires-python = ">=3.12" + dynamic = ["version"] + + [build-system] + requires = ["setuptools"] + build-backend = "setuptools.build_meta" + + [tool.uv] + cache-keys = [{ file = "pyproject.toml" }, { file = "src/dynamic/__init__.py" }] + + [tool.setuptools.dynamic] + version = { attr = "dynamic.__version__" } + + [tool.setuptools] + package-dir = { "" = "src" } + + [tool.setuptools.packages.find] + where = ["src"] + "#, + )?; + + context + .temp_dir + .child("dynamic") + .child("src") + .child("dynamic") + .child("__init__.py") + .write_str("__version__ = '0.1.0'")?; + uv_snapshot!(context.filters(), context.lock(), @r###" success: true exit_code: 0 ----- stdout ----- ----- stderr ----- - Resolved 1 package in [TIME] - Updated project v0.1.0 -> v0.1.1 + Resolved 3 packages in [TIME] "###); let lock = fs_err::read_to_string(context.temp_dir.join("uv.lock")).unwrap(); @@ -19932,10 +20008,282 @@ fn lock_dynamic_version() -> Result<()> { [options] exclude-newer = "2024-03-25T00:00:00Z" + [manifest] + members = [ + "dynamic", + "project", + ] + + [[package]] + name = "dynamic" + source = { editable = "dynamic" } + + [[package]] + name = "iniconfig" + version = "2.0.0" + source = { registry = "https://pypi.org/simple" } + sdist = { url = "https://files.pythonhosted.org/packages/d7/4b/cbd8e699e64a6f16ca3a8220661b5f83792b3017d0f79807cb8708d33913/iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3", size = 4646 } + wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/a6/62565a6e1cf69e10f5727360368e451d4b7f58beeac6173dc9db836a5b46/iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374", size = 5892 }, + ] + [[package]] name = "project" - version = "0.1.1" - source = { editable = "." } + version = "0.1.0" + source = { virtual = "." } + dependencies = [ + { name = "dynamic" }, + { name = "iniconfig" }, + ] + + [package.metadata] + requires-dist = [ + { name = "dynamic", editable = "dynamic" }, + { name = "iniconfig", specifier = ">=2" }, + ] + "### + ); + }); + + // Bump the version. + context + .temp_dir + .child("dynamic") + .child("src") + .child("dynamic") + .child("__init__.py") + .write_str("__version__ = '0.1.1'")?; + + // Re-run with `--locked`. We should accept the lockfile, since dynamic versions are omitted. + uv_snapshot!(context.filters(), context.lock().arg("--locked"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 3 packages in [TIME] + "###); + + let lock = fs_err::read_to_string(context.temp_dir.join("uv.lock")).unwrap(); + + insta::with_settings!({ + filters => context.filters(), + }, { + assert_snapshot!( + lock, @r###" + version = 1 + requires-python = ">=3.12" + + [options] + exclude-newer = "2024-03-25T00:00:00Z" + + [manifest] + members = [ + "dynamic", + "project", + ] + + [[package]] + name = "dynamic" + source = { editable = "dynamic" } + + [[package]] + name = "iniconfig" + version = "2.0.0" + source = { registry = "https://pypi.org/simple" } + sdist = { url = "https://files.pythonhosted.org/packages/d7/4b/cbd8e699e64a6f16ca3a8220661b5f83792b3017d0f79807cb8708d33913/iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3", size = 4646 } + wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/a6/62565a6e1cf69e10f5727360368e451d4b7f58beeac6173dc9db836a5b46/iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374", size = 5892 }, + ] + + [[package]] + name = "project" + version = "0.1.0" + source = { virtual = "." } + dependencies = [ + { name = "dynamic" }, + { name = "iniconfig" }, + ] + + [package.metadata] + requires-dist = [ + { name = "dynamic", editable = "dynamic" }, + { name = "iniconfig", specifier = ">=2" }, + ] + "### + ); + }); + + Ok(()) +} + +/// Lock a package that depends on a package with a dynamic version using a `path` source (as +/// opposed to a workspace). +#[test] +fn lock_dynamic_version_path_dependency() -> Result<()> { + let context = TestContext::new("3.12"); + + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str( + r#" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = ["dynamic", "iniconfig>=2"] + + [tool.uv.sources] + dynamic = { path = "dynamic" } + "#, + )?; + + // Create a project with a dynamic version. + let pyproject_toml = context.temp_dir.child("dynamic").child("pyproject.toml"); + pyproject_toml.write_str( + r#" + [project] + name = "dynamic" + requires-python = ">=3.12" + dynamic = ["version"] + + [build-system] + requires = ["setuptools"] + build-backend = "setuptools.build_meta" + + [tool.uv] + cache-keys = [{ file = "pyproject.toml" }, { file = "src/dynamic/__init__.py" }] + + [tool.setuptools.dynamic] + version = { attr = "dynamic.__version__" } + + [tool.setuptools] + package-dir = { "" = "src" } + + [tool.setuptools.packages.find] + where = ["src"] + "#, + )?; + + context + .temp_dir + .child("dynamic") + .child("src") + .child("dynamic") + .child("__init__.py") + .write_str("__version__ = '0.1.0'")?; + + uv_snapshot!(context.filters(), context.lock(), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 3 packages in [TIME] + "###); + + let lock = fs_err::read_to_string(context.temp_dir.join("uv.lock")).unwrap(); + + insta::with_settings!({ + filters => context.filters(), + }, { + assert_snapshot!( + lock, @r###" + version = 1 + requires-python = ">=3.12" + + [options] + exclude-newer = "2024-03-25T00:00:00Z" + + [[package]] + name = "dynamic" + source = { directory = "dynamic" } + + [[package]] + name = "iniconfig" + version = "2.0.0" + source = { registry = "https://pypi.org/simple" } + sdist = { url = "https://files.pythonhosted.org/packages/d7/4b/cbd8e699e64a6f16ca3a8220661b5f83792b3017d0f79807cb8708d33913/iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3", size = 4646 } + wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/a6/62565a6e1cf69e10f5727360368e451d4b7f58beeac6173dc9db836a5b46/iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374", size = 5892 }, + ] + + [[package]] + name = "project" + version = "0.1.0" + source = { virtual = "." } + dependencies = [ + { name = "dynamic" }, + { name = "iniconfig" }, + ] + + [package.metadata] + requires-dist = [ + { name = "dynamic", directory = "dynamic" }, + { name = "iniconfig", specifier = ">=2" }, + ] + "### + ); + }); + + // Bump the version. + context + .temp_dir + .child("dynamic") + .child("src") + .child("dynamic") + .child("__init__.py") + .write_str("__version__ = '0.1.1'")?; + + // Re-run with `--locked`. We should accept the lockfile, since dynamic versions are omitted. + uv_snapshot!(context.filters(), context.lock().arg("--locked"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 3 packages in [TIME] + "###); + + let lock = fs_err::read_to_string(context.temp_dir.join("uv.lock")).unwrap(); + + insta::with_settings!({ + filters => context.filters(), + }, { + assert_snapshot!( + lock, @r###" + version = 1 + requires-python = ">=3.12" + + [options] + exclude-newer = "2024-03-25T00:00:00Z" + + [[package]] + name = "dynamic" + source = { directory = "dynamic" } + + [[package]] + name = "iniconfig" + version = "2.0.0" + source = { registry = "https://pypi.org/simple" } + sdist = { url = "https://files.pythonhosted.org/packages/d7/4b/cbd8e699e64a6f16ca3a8220661b5f83792b3017d0f79807cb8708d33913/iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3", size = 4646 } + wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/a6/62565a6e1cf69e10f5727360368e451d4b7f58beeac6173dc9db836a5b46/iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374", size = 5892 }, + ] + + [[package]] + name = "project" + version = "0.1.0" + source = { virtual = "." } + dependencies = [ + { name = "dynamic" }, + { name = "iniconfig" }, + ] + + [package.metadata] + requires-dist = [ + { name = "dynamic", directory = "dynamic" }, + { name = "iniconfig", specifier = ">=2" }, + ] "### ); }); diff --git a/crates/uv/tests/it/snapshots/it__ecosystem__black-lock-file.snap b/crates/uv/tests/it/snapshots/it__ecosystem__black-lock-file.snap index 19f1f6611344..aa69b9d056a4 100644 --- a/crates/uv/tests/it/snapshots/it__ecosystem__black-lock-file.snap +++ b/crates/uv/tests/it/snapshots/it__ecosystem__black-lock-file.snap @@ -179,7 +179,6 @@ wheels = [ [[package]] name = "black" -version = "24.8.0" source = { editable = "." } dependencies = [ { name = "click" },