Invalidate lockfile when static versions change (#10858)
Some checks are pending
CI / Determine changes (push) Waiting to run
CI / lint (push) Waiting to run
CI / cargo clippy | ubuntu (push) Blocked by required conditions
CI / cargo clippy | windows (push) Blocked by required conditions
CI / cargo dev generate-all (push) Blocked by required conditions
CI / cargo shear (push) Waiting to run
CI / cargo test | ubuntu (push) Blocked by required conditions
CI / cargo test | macos (push) Blocked by required conditions
CI / cargo test | windows (push) Blocked by required conditions
CI / check windows trampoline | aarch64 (push) Blocked by required conditions
CI / check windows trampoline | i686 (push) Blocked by required conditions
CI / check windows trampoline | x86_64 (push) Blocked by required conditions
CI / test windows trampoline | i686 (push) Blocked by required conditions
CI / test windows trampoline | x86_64 (push) Blocked by required conditions
CI / typos (push) Waiting to run
CI / mkdocs (push) Waiting to run
CI / build binary | linux (push) Blocked by required conditions
CI / build binary | macos aarch64 (push) Blocked by required conditions
CI / build binary | macos x86_64 (push) Blocked by required conditions
CI / build binary | windows (push) Blocked by required conditions
CI / cargo build (msrv) (push) Blocked by required conditions
CI / build binary | freebsd (push) Blocked by required conditions
CI / ecosystem test | prefecthq/prefect (push) Blocked by required conditions
CI / ecosystem test | pallets/flask (push) Blocked by required conditions
CI / smoke test | linux (push) Blocked by required conditions
CI / smoke test | macos (push) Blocked by required conditions
CI / smoke test | windows (push) Blocked by required conditions
CI / integration test | conda on ubuntu (push) Blocked by required conditions
CI / integration test | deadsnakes python3.9 on ubuntu (push) Blocked by required conditions
CI / integration test | free-threaded on linux (push) Blocked by required conditions
CI / integration test | free-threaded on windows (push) Blocked by required conditions
CI / integration test | pypy on ubuntu (push) Blocked by required conditions
CI / integration test | pypy on windows (push) Blocked by required conditions
CI / integration test | graalpy on ubuntu (push) Blocked by required conditions
CI / integration test | graalpy on windows (push) Blocked by required conditions
CI / integration test | github actions (push) Blocked by required conditions
CI / integration test | determine publish changes (push) Blocked by required conditions
CI / integration test | uv publish (push) Blocked by required conditions
CI / check cache | ubuntu (push) Blocked by required conditions
CI / check cache | macos aarch64 (push) Blocked by required conditions
CI / check system | python on debian (push) Blocked by required conditions
CI / check system | python on fedora (push) Blocked by required conditions
CI / check system | python on ubuntu (push) Blocked by required conditions
CI / check system | python on opensuse (push) Blocked by required conditions
CI / check system | homebrew python on macos aarch64 (push) Blocked by required conditions
CI / check system | python on rocky linux 8 (push) Blocked by required conditions
CI / check system | python on rocky linux 9 (push) Blocked by required conditions
CI / check system | pypy on ubuntu (push) Blocked by required conditions
CI / check system | pyston (push) Blocked by required conditions
CI / check system | alpine (push) Blocked by required conditions
CI / check system | python on macos aarch64 (push) Blocked by required conditions
CI / check system | python on macos x86_64 (push) Blocked by required conditions
CI / check system | python3.10 on windows (push) Blocked by required conditions
CI / check system | python3.10 on windows x86 (push) Blocked by required conditions
CI / check system | python3.13 on windows (push) Blocked by required conditions
CI / check system | python3.12 via chocolatey (push) Blocked by required conditions
CI / check system | python3.9 via pyenv (push) Blocked by required conditions
CI / check system | python3.13 (push) Blocked by required conditions
CI / check system | conda3.11 on linux (push) Blocked by required conditions
CI / check system | conda3.8 on linux (push) Blocked by required conditions
CI / check system | conda3.11 on macos (push) Blocked by required conditions
CI / check system | conda3.8 on macos (push) Blocked by required conditions
CI / check system | conda3.11 on windows (push) Blocked by required conditions
CI / check system | conda3.8 on windows (push) Blocked by required conditions
CI / check system | amazonlinux (push) Blocked by required conditions
CI / check system | embedded python3.10 on windows (push) Blocked by required conditions
CI / benchmarks (push) Blocked by required conditions

## Summary

We should only be ignoring changes in `version` for dynamic projects;
for static projects, it should still be enforced. We should also be
invalidating the lockfile if a project goes from static to dynamic or
vice versa.

Closes #10852.
This commit is contained in:
Charlie Marsh 2025-01-22 12:58:15 -05:00 committed by GitHub
parent 434706389b
commit f5447ce965
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 541 additions and 99 deletions

View file

@ -1067,32 +1067,17 @@ impl Lock {
}
}
// Validate that the member sources have not changed.
{
// E.g., that they've switched from virtual to non-virtual or vice versa.
for (name, member) in packages {
let expected = !member.pyproject_toml().is_package();
let actual = self
.find_by_name(name)
.ok()
.flatten()
.map(|package| matches!(package.id.source, Source::Virtual(_)));
if actual != Some(expected) {
return Ok(SatisfiesResult::MismatchedVirtual(name.clone(), expected));
}
}
// E.g., that they've switched from dynamic to non-dynamic or vice versa.
for (name, member) in packages {
let expected = member.pyproject_toml().is_dynamic();
let actual = self
.find_by_name(name)
.ok()
.flatten()
.map(Package::is_dynamic);
if actual != Some(expected) {
return Ok(SatisfiesResult::MismatchedDynamic(name.clone(), expected));
}
// Validate that the member sources have not changed (e.g., that they've switched from
// virtual to non-virtual or vice versa).
for (name, member) in packages {
let expected = !member.pyproject_toml().is_package();
let actual = self
.find_by_name(name)
.ok()
.flatten()
.map(|package| matches!(package.id.source, Source::Virtual(_)));
if actual != Some(expected) {
return Ok(SatisfiesResult::MismatchedVirtual(name.clone(), expected));
}
}
@ -1287,60 +1272,10 @@ impl Lock {
continue;
}
// Fetch the metadata for the distribution.
//
// If the distribution is a source tree, attempt to extract the requirements from the
// `pyproject.toml` directly. The distribution database will do this too, but we can be
// even more aggressive here since we _only_ need the requirements. So, for example,
// even if the version is dynamic, we can still extract the requirements without
// performing a build, unlike in the database where we typically construct a "complete"
// metadata object.
let metadata = if let Some(source_tree) = package.id.source.as_source_tree() {
database
.requires_dist(root.join(source_tree))
.await
.map_err(|err| LockErrorKind::Resolution {
id: package.id.clone(),
err,
})?
} else {
None
};
let satisfied = metadata.is_some_and(|metadata| {
match satisfies_requires_dist(metadata, package, root) {
Ok(SatisfiesResult::Satisfied) => {
debug!("Static `requires-dist` for `{}` is up-to-date", package.id);
true
},
Ok(..) => {
debug!("Static `requires-dist` for `{}` is out-of-date; falling back to distribution database", package.id);
false
},
Err(..) => {
debug!("Static `requires-dist` for `{}` is invalid; falling back to distribution database", package.id);
false
},
}
});
// If the `requires-dist` metadata matches the requirements, we're done; otherwise,
// fetch the "full" metadata, which may involve invoking the build system. In some
// cases, build backends return metadata that does _not_ match the `pyproject.toml`
// exactly. For example, `hatchling` will flatten any recursive (or self-referential)
// extras, while `setuptools` will not.
if !satisfied {
// Get the metadata for the distribution.
let dist = package.to_dist(
root,
// When validating, it's okay to use wheels that don't match the current platform.
TagPolicy::Preferred(tags),
// When validating, it's okay to use (e.g.) a source distribution with `--no-build`.
// We're just trying to determine whether the lockfile is up-to-date. If we end
// up needing to build a source distribution in order to do so, below, we'll error
// there.
&BuildOptions::default(),
)?;
if let Some(version) = package.id.version.as_ref() {
// For a non-dynamic package, fetch the metadata from the distribution database.
let dist =
package.to_dist(root, TagPolicy::Preferred(tags), &BuildOptions::default())?;
let metadata = {
let id = dist.version_id();
@ -1380,10 +1315,139 @@ impl Lock {
}
};
// If this is a local package, validate that it hasn't become dynamic (in which
// case, we'd expect the version to be omitted).
if package.id.source.is_source_tree() {
if metadata.dynamic {
return Ok(SatisfiesResult::MismatchedDynamic(
package.id.name.clone(),
false,
));
}
}
// Validate the `version` metadata.
if metadata.version != *version {
return Ok(SatisfiesResult::MismatchedVersion(
package.id.name.clone(),
version.clone(),
Some(metadata.version.clone()),
));
}
// Validate that the requirements are unchanged.
match satisfies_requires_dist(RequiresDist::from(metadata), package, root)? {
SatisfiesResult::Satisfied => {}
result => return Ok(result),
}
} else if let Some(source_tree) = package.id.source.as_source_tree() {
// For dynamic packages, we don't need the version. We only need to know that the
// package is still dynamic, and that the requirements are unchanged.
//
// If the distribution is a source tree, attempt to extract the requirements from the
// `pyproject.toml` directly. The distribution database will do this too, but we can be
// even more aggressive here since we _only_ need the requirements. So, for example,
// even if the version is dynamic, we can still extract the requirements without
// performing a build, unlike in the database where we typically construct a "complete"
// metadata object.
let metadata = database
.requires_dist(root.join(source_tree))
.await
.map_err(|err| LockErrorKind::Resolution {
id: package.id.clone(),
err,
})?;
let satisfied = metadata.is_some_and(|metadata| {
// Validate that the package is still dynamic.
if !metadata.dynamic {
debug!("Static `requires-dist` for `{}` is out-of-date; falling back to distribution database", package.id);
return false;
}
// Validate that the requirements are unchanged.
match satisfies_requires_dist(metadata, package, root) {
Ok(SatisfiesResult::Satisfied) => {
debug!("Static `requires-dist` for `{}` is up-to-date", package.id);
true
},
Ok(..) => {
debug!("Static `requires-dist` for `{}` is out-of-date; falling back to distribution database", package.id);
false
},
Err(..) => {
debug!("Static `requires-dist` for `{}` is invalid; falling back to distribution database", package.id);
false
},
}
});
// If the `requires-dist` metadata matches the requirements, we're done; otherwise,
// fetch the "full" metadata, which may involve invoking the build system. In some
// cases, build backends return metadata that does _not_ match the `pyproject.toml`
// exactly. For example, `hatchling` will flatten any recursive (or self-referential)
// extras, while `setuptools` will not.
if !satisfied {
let dist = package.to_dist(
root,
TagPolicy::Preferred(tags),
&BuildOptions::default(),
)?;
let metadata = {
let id = dist.version_id();
if let Some(archive) =
index
.distributions()
.get(&id)
.as_deref()
.and_then(|response| {
if let MetadataResponse::Found(archive, ..) = response {
Some(archive)
} else {
None
}
})
{
// If the metadata is already in the index, return it.
archive.metadata.clone()
} else {
// Run the PEP 517 build process to extract metadata from the source distribution.
let archive = database
.get_or_build_wheel_metadata(&dist, hasher.get(&dist))
.await
.map_err(|err| LockErrorKind::Resolution {
id: package.id.clone(),
err,
})?;
let metadata = archive.metadata.clone();
// Insert the metadata into the index.
index
.distributions()
.done(id, Arc::new(MetadataResponse::Found(archive)));
metadata
}
};
// Validate that the package is still dynamic.
if !metadata.dynamic {
return Ok(SatisfiesResult::MismatchedDynamic(
package.id.name.clone(),
true,
));
}
// Validate that the requirements are unchanged.
match satisfies_requires_dist(RequiresDist::from(metadata), package, root)? {
SatisfiesResult::Satisfied => {}
result => return Ok(result),
}
}
} else {
return Ok(SatisfiesResult::MissingVersion(package.id.name.clone()));
}
// Recurse.
@ -1446,7 +1510,7 @@ pub enum SatisfiesResult<'lock> {
MismatchedMembers(BTreeSet<PackageName>, &'lock BTreeSet<PackageName>),
/// 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.
/// A source tree 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>),
@ -1483,6 +1547,8 @@ pub enum SatisfiesResult<'lock> {
BTreeMap<GroupName, BTreeSet<Requirement>>,
BTreeMap<GroupName, BTreeSet<Requirement>>,
),
/// The lockfile is missing a version.
MissingVersion(PackageName),
}
/// We discard the lockfile if these options match.