Omit dynamic versions from the lockfile (#10622)

## Summary

This PR modifies the lockfile to omit versions for source trees that use
`dynamic` versioning, thereby enabling projects to use dynamic
versioning with `uv.lock`.

Prior to this change, dynamic versioning was largely incompatible with
locking, especially for popular tools like `setuptools_scm` -- in that
case, every commit bumps the version, so every commit invalidates the
committed lockfile.

Closes https://github.com/astral-sh/uv/issues/7533.
This commit is contained in:
Charlie Marsh 2025-01-15 11:54:32 -05:00 committed by GitHub
parent d20a48a5b4
commit 0617fd5da6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
28 changed files with 990 additions and 366 deletions

View file

@ -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,
})
}
}

View file

@ -50,6 +50,7 @@ pub struct Metadata {
pub requires_python: Option<VersionSpecifiers>,
pub provides_extras: Vec<ExtraName>,
pub dependency_groups: BTreeMap<GroupName, Vec<uv_pypi_types::Requirement>>,
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,
})
}
}

View file

@ -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,122 +2117,6 @@ impl<'a, T: BuildContext> SourceDistributionBuilder<'a, T> {
Ok(Some(metadata))
}
async fn read_static_metadata(
source: &BuildableSource<'_>,
source_root: &Path,
subdirectory: Option<&Path>,
) -> Result<Option<ResolutionMetadata>, Error> {
// Attempt to read static metadata from the `pyproject.toml`.
match read_pyproject_toml(source_root, subdirectory, source.version()).await {
Ok(metadata) => {
debug!("Found static `pyproject.toml` for: {source}");
// Validate the metadata, but ignore it if the metadata doesn't match.
match validate_metadata(source, &metadata) {
Ok(()) => {
return Ok(Some(metadata));
}
Err(Error::WheelMetadataNameMismatch { metadata, given }) => {
debug!(
"Ignoring `pyproject.toml` for: {source} (metadata: {metadata}, given: {given})"
);
}
Err(err) => return Err(err),
}
}
Err(
err @ (Error::MissingPyprojectToml
| Error::PyprojectToml(
uv_pypi_types::MetadataError::Pep508Error(_)
| uv_pypi_types::MetadataError::DynamicField(_)
| uv_pypi_types::MetadataError::FieldNotFound(_)
| uv_pypi_types::MetadataError::PoetrySyntax,
)),
) => {
debug!("No static `pyproject.toml` available for: {source} ({err:?})");
}
Err(err) => return Err(err),
}
// 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);
}
// Attempt to read static metadata from the `PKG-INFO` file.
match read_pkg_info(source_root, subdirectory).await {
Ok(metadata) => {
debug!("Found static `PKG-INFO` for: {source}");
// Validate the metadata, but ignore it if the metadata doesn't match.
match validate_metadata(source, &metadata) {
Ok(()) => {
return Ok(Some(metadata));
}
Err(Error::WheelMetadataNameMismatch { metadata, given }) => {
debug!(
"Ignoring `PKG-INFO` for: {source} (metadata: {metadata}, given: {given})"
);
}
Err(err) => return Err(err),
}
}
Err(
err @ (Error::MissingPkgInfo
| Error::PkgInfo(
uv_pypi_types::MetadataError::Pep508Error(_)
| uv_pypi_types::MetadataError::DynamicField(_)
| uv_pypi_types::MetadataError::FieldNotFound(_)
| uv_pypi_types::MetadataError::UnsupportedMetadataVersion(_),
)),
) => {
debug!("No static `PKG-INFO` available for: {source} ({err:?})");
}
Err(err) => return Err(err),
}
// Attempt to read static metadata from the `egg-info` directory.
match read_egg_info(source_root, subdirectory, source.name(), source.version()).await {
Ok(metadata) => {
debug!("Found static `egg-info` for: {source}");
// Validate the metadata, but ignore it if the metadata doesn't match.
match validate_metadata(source, &metadata) {
Ok(()) => {
return Ok(Some(metadata));
}
Err(Error::WheelMetadataNameMismatch { metadata, given }) => {
debug!(
"Ignoring `egg-info` for: {source} (metadata: {metadata}, given: {given})"
);
}
Err(err) => return Err(err),
}
}
Err(
err @ (Error::MissingEggInfo
| Error::MissingRequiresTxt
| Error::MissingPkgInfo
| Error::RequiresTxt(
uv_pypi_types::MetadataError::Pep508Error(_)
| uv_pypi_types::MetadataError::RequiresTxtContents(_),
)
| Error::PkgInfo(
uv_pypi_types::MetadataError::Pep508Error(_)
| uv_pypi_types::MetadataError::DynamicField(_)
| uv_pypi_types::MetadataError::FieldNotFound(_)
| uv_pypi_types::MetadataError::UnsupportedMetadataVersion(_),
)),
) => {
debug!("No static `egg-info` available for: {source} ({err:?})");
}
Err(err) => return Err(err),
}
Ok(None)
}
/// Returns a GET [`reqwest::Request`] for the given URL.
fn request(url: Url, client: &RegistryClient) -> Result<reqwest::Request, reqwest::Error> {
client
@ -2225,6 +2201,146 @@ pub fn prune(cache: &Cache) -> Result<Removal, Error> {
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<Self, Error> {
// Attempt to read static metadata from the `pyproject.toml`.
match read_pyproject_toml(source_root, subdirectory, source.version()).await {
Ok(metadata) => {
debug!("Found static `pyproject.toml` for: {source}");
// Validate the metadata, but ignore it if the metadata doesn't match.
match validate_metadata(source, &metadata) {
Ok(()) => {
return Ok(Self::Some(metadata));
}
Err(Error::WheelMetadataNameMismatch { metadata, given }) => {
debug!(
"Ignoring `pyproject.toml` for: {source} (metadata: {metadata}, given: {given})"
);
}
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(
uv_pypi_types::MetadataError::Pep508Error(_)
| uv_pypi_types::MetadataError::DynamicField(_)
| uv_pypi_types::MetadataError::FieldNotFound(_)
| uv_pypi_types::MetadataError::PoetrySyntax,
)),
) => {
debug!("No static `pyproject.toml` available for: {source} ({err:?})");
}
Err(err) => return Err(err),
}
// 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(Self::None);
}
// Attempt to read static metadata from the `PKG-INFO` file.
match read_pkg_info(source_root, subdirectory).await {
Ok(metadata) => {
debug!("Found static `PKG-INFO` for: {source}");
// Validate the metadata, but ignore it if the metadata doesn't match.
match validate_metadata(source, &metadata) {
Ok(()) => {
return Ok(Self::Some(metadata));
}
Err(Error::WheelMetadataNameMismatch { metadata, given }) => {
debug!(
"Ignoring `PKG-INFO` for: {source} (metadata: {metadata}, given: {given})"
);
}
Err(err) => return Err(err),
}
}
Err(
err @ (Error::MissingPkgInfo
| Error::PkgInfo(
uv_pypi_types::MetadataError::Pep508Error(_)
| uv_pypi_types::MetadataError::DynamicField(_)
| uv_pypi_types::MetadataError::FieldNotFound(_)
| uv_pypi_types::MetadataError::UnsupportedMetadataVersion(_),
)),
) => {
debug!("No static `PKG-INFO` available for: {source} ({err:?})");
}
Err(err) => return Err(err),
}
// Attempt to read static metadata from the `egg-info` directory.
match read_egg_info(source_root, subdirectory, source.name(), source.version()).await {
Ok(metadata) => {
debug!("Found static `egg-info` for: {source}");
// Validate the metadata, but ignore it if the metadata doesn't match.
match validate_metadata(source, &metadata) {
Ok(()) => {
return Ok(Self::Some(metadata));
}
Err(Error::WheelMetadataNameMismatch { metadata, given }) => {
debug!(
"Ignoring `egg-info` for: {source} (metadata: {metadata}, given: {given})"
);
}
Err(err) => return Err(err),
}
}
Err(
err @ (Error::MissingEggInfo
| Error::MissingRequiresTxt
| Error::MissingPkgInfo
| Error::RequiresTxt(
uv_pypi_types::MetadataError::Pep508Error(_)
| uv_pypi_types::MetadataError::RequiresTxtContents(_),
)
| Error::PkgInfo(
uv_pypi_types::MetadataError::Pep508Error(_)
| uv_pypi_types::MetadataError::DynamicField(_)
| uv_pypi_types::MetadataError::FieldNotFound(_)
| uv_pypi_types::MetadataError::UnsupportedMetadataVersion(_),
)),
) => {
debug!("No static `egg-info` available for: {source} ({err:?})");
}
Err(err) => return Err(err),
}
Ok(Self::None)
}
}
/// 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,
})
}

View file

@ -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 <https://peps.python.org/pep-0345/>.
#[derive(Deserialize, Debug, Clone)]
@ -15,6 +16,7 @@ pub struct Metadata12 {
pub name: PackageName,
pub version: Version,
pub requires_python: Option<VersionSpecifiers>,
pub dynamic: Vec<String>,
}
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::<Vec<_>>();
Ok(Self {
name,
version,
requires_python,
dynamic,
})
}
}

View file

@ -29,6 +29,9 @@ pub struct ResolutionMetadata {
pub requires_dist: Vec<Requirement<VerbatimParsedUrl>>,
pub requires_python: Option<VersionSpecifiers>,
pub provides_extras: Vec<ExtraName>,
/// Whether the version field is dynamic.
#[serde(default)]
pub dynamic: bool,
}
/// From <https://github.com/PyO3/python-pkginfo-rs/blob/d719988323a0cfea86d4737116d7917f30e819e2/src/metadata.rs#LL78C2-L91C26>
@ -68,6 +71,9 @@ impl ResolutionMetadata {
}
})
.collect::<Vec<_>>();
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::<Vec<_>>();
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,
})
}

View file

@ -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,
})
}

View file

@ -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()? {

View file

@ -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,

View file

@ -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<PackageName>, &'lock BTreeSet<PackageName>),
/// 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<Version>),
/// 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<Requirement>,
BTreeSet<Requirement>,
),
/// A package in the lockfile contains different `dependency-group` metadata than expected.
MismatchedPackageDependencyGroups(
&'lock PackageName,
&'lock Version,
Option<&'lock Version>,
BTreeMap<GroupName, BTreeSet<Requirement>>,
BTreeMap<GroupName, BTreeSet<Requirement>>,
),
@ -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<Version>,
source: Source,
}
@ -2461,9 +2469,20 @@ impl PackageId {
annotated_dist: &AnnotatedDist,
root: &Path,
) -> Result<PackageId, LockError> {
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<PackageName, PackageId>,
) -> Result<PackageId, LockError> {
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<PackageId> 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!(
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.package_id.version, self.package_id.source
)
} else {
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(",")
),
}
}
}

View file

@ -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

View file

@ -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(

View file

@ -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(

View file

@ -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",
),

View file

@ -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(
@ -148,24 +154,13 @@ Ok(
},
],
by_id: {
PackageId {
name: PackageName(
"a",
),
version: "0.1.0",
source: Registry(
Url(
UrlString(
"https://pypi.org/simple",
),
),
),
}: 0,
PackageId {
name: PackageName(
"b",
),
version: "0.1.0",
version: Some(
"0.1.0",
),
source: Registry(
Url(
UrlString(
@ -174,6 +169,21 @@ Ok(
),
),
}: 1,
PackageId {
name: PackageName(
"a",
),
version: Some(
"0.1.0",
),
source: Registry(
Url(
UrlString(
"https://pypi.org/simple",
),
),
),
}: 0,
},
manifest: ResolverManifest {
members: {},

View file

@ -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(
@ -148,24 +154,13 @@ Ok(
},
],
by_id: {
PackageId {
name: PackageName(
"a",
),
version: "0.1.0",
source: Registry(
Url(
UrlString(
"https://pypi.org/simple",
),
),
),
}: 0,
PackageId {
name: PackageName(
"b",
),
version: "0.1.0",
version: Some(
"0.1.0",
),
source: Registry(
Url(
UrlString(
@ -174,6 +169,21 @@ Ok(
),
),
}: 1,
PackageId {
name: PackageName(
"a",
),
version: Some(
"0.1.0",
),
source: Registry(
Url(
UrlString(
"https://pypi.org/simple",
),
),
),
}: 0,
},
manifest: ResolverManifest {
members: {},

View file

@ -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(
@ -148,24 +154,13 @@ Ok(
},
],
by_id: {
PackageId {
name: PackageName(
"a",
),
version: "0.1.0",
source: Registry(
Url(
UrlString(
"https://pypi.org/simple",
),
),
),
}: 0,
PackageId {
name: PackageName(
"b",
),
version: "0.1.0",
version: Some(
"0.1.0",
),
source: Registry(
Url(
UrlString(
@ -174,6 +169,21 @@ Ok(
),
),
}: 1,
PackageId {
name: PackageName(
"a",
),
version: Some(
"0.1.0",
),
source: Registry(
Url(
UrlString(
"https://pypi.org/simple",
),
),
),
}: 0,
},
manifest: ResolverManifest {
members: {},

View file

@ -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",

View file

@ -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",

View file

@ -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",
),

View file

@ -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",
),

View file

@ -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 {

View file

@ -95,15 +95,18 @@ impl Preference {
pub fn from_lock(
package: &crate::lock::Package,
install_path: &Path,
) -> Result<Self, LockError> {
Ok(Self {
) -> Result<Option<Self>, 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`].

View file

@ -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 {

View file

@ -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);

View file

@ -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
},
);

View file

@ -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);
}
}

View file

@ -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,14 @@ 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
----- 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.
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
"###);
let lock = fs_err::read_to_string(context.temp_dir.join("uv.lock")).unwrap();
@ -19934,7 +19921,6 @@ fn lock_dynamic_version() -> Result<()> {
[[package]]
name = "project"
version = "0.1.1"
source = { editable = "." }
"###
);
@ -19943,6 +19929,368 @@ fn lock_dynamic_version() -> Result<()> {
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 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" },
]
"###
);
});
// 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" },
]
"###
);
});
Ok(())
}
#[test]
fn lock_derivation_chain_prod() -> Result<()> {
let context = TestContext::new("3.12");

View file

@ -179,7 +179,6 @@ wheels = [
[[package]]
name = "black"
version = "24.8.0"
source = { editable = "." }
dependencies = [
{ name = "click" },