Use a 'minor' version field (revision) in the lockfile (#11500)

## Summary

This is an alternative to the approach we took in #11063 whereby we
always included `provides-extra` and `requires-dist`, since we needed
some way to differentiate between "no extras" and "lockfile was
generated by a uv version that didn't include extras".

Instead, this PR adds a minor version (called a "revision") to the
lockfile that we can use to indicate support for this feature. While
lockfile version bumps are backwards-incompatible, older uv versions
_can_ read lockfiles with a later revision -- they just won't understand
all the data.

In a future major version bump, we could simplify things and change the
schema to use a (major, minor) format instead of these two separate
fields. But this is the only way to do it that's backwards-compatible
with existing uv versions.

---------

Co-authored-by: Zanie Blue <contact@zanie.dev>
This commit is contained in:
Charlie Marsh 2025-02-14 11:17:26 -05:00 committed by GitHub
parent f001605505
commit 29bdf1d597
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
35 changed files with 738 additions and 859 deletions

View file

@ -66,6 +66,9 @@ mod tree;
/// The current version of the lockfile format.
pub const VERSION: u32 = 1;
/// The current revision of the lockfile format.
const REVISION: u32 = 1;
static LINUX_MARKERS: LazyLock<UniversalMarker> = LazyLock::new(|| {
let pep508 = MarkerTree::from_str("os_name == 'posix' and sys_platform == 'linux'").unwrap();
UniversalMarker::new(pep508, ConflictMarker::TRUE)
@ -101,7 +104,21 @@ static X86_MARKERS: LazyLock<UniversalMarker> = LazyLock::new(|| {
#[derive(Clone, Debug, serde::Deserialize)]
#[serde(try_from = "LockWire")]
pub struct Lock {
/// The (major) version of the lockfile format.
///
/// Changes to the major version indicate backwards- and forwards-incompatible changes to the
/// lockfile format. A given uv version only supports a single major version of the lockfile
/// format.
///
/// In other words, a version of uv that supports version 2 of the lockfile format will not be
/// able to read lockfiles generated under version 1 or 3.
version: u32,
/// The revision of the lockfile format.
///
/// Changes to the revision indicate backwards-compatible changes to the lockfile format.
/// In other words, versions of uv that only support revision 1 _will_ be able to read lockfiles
/// with a revision greater than 1 (though they may ignore newer fields).
revision: u32,
/// If this lockfile was built from a forking resolution with non-identical forks, store the
/// forks in the lockfile so we can recreate them in subsequent resolutions.
fork_markers: Vec<UniversalMarker>,
@ -262,6 +279,7 @@ impl Lock {
};
let lock = Self::new(
VERSION,
REVISION,
packages,
requires_python,
options,
@ -347,6 +365,7 @@ impl Lock {
/// Initialize a [`Lock`] from a list of [`Package`] entries.
fn new(
version: u32,
revision: u32,
mut packages: Vec<Package>,
requires_python: RequiresPython,
options: ResolverOptions,
@ -500,6 +519,7 @@ impl Lock {
}
let lock = Self {
version,
revision,
fork_markers,
conflicts,
supported_environments,
@ -550,6 +570,11 @@ impl Lock {
self.version
}
/// Returns the lockfile revision.
pub fn revision(&self) -> u32 {
self.revision
}
/// Returns the number of packages in the lockfile.
pub fn len(&self) -> usize {
self.packages.len()
@ -661,6 +686,10 @@ impl Lock {
let mut doc = toml_edit::DocumentMut::new();
doc.insert("version", value(i64::from(self.version)));
if self.revision > 0 {
doc.insert("revision", value(i64::from(self.revision)));
}
doc.insert("requires-python", value(self.requires_python.to_string()));
if !self.fork_markers.is_empty() {
@ -981,7 +1010,6 @@ impl Lock {
.metadata
.requires_dist
.iter()
.flatten()
.cloned()
.map(|requirement| normalize_requirement(requirement, root))
.collect::<Result<_, _>>()?;
@ -1662,6 +1690,7 @@ impl ResolverManifest {
#[serde(rename_all = "kebab-case")]
struct LockWire {
version: u32,
revision: Option<u32>,
requires_python: RequiresPython,
/// If this lockfile was built from a forking resolution with non-identical forks, store the
/// forks in the lockfile so we can recreate them in subsequent resolutions.
@ -1719,6 +1748,7 @@ impl TryFrom<LockWire> for Lock {
.collect();
let lock = Lock::new(
wire.version,
wire.revision.unwrap_or(0),
packages,
wire.requires_python,
wire.options,
@ -1778,32 +1808,28 @@ impl Package {
let sdist = SourceDist::from_annotated_dist(&id, annotated_dist)?;
let wheels = Wheel::from_annotated_dist(annotated_dist)?;
let requires_dist = if id.source.is_immutable() {
None
BTreeSet::default()
} else {
Some(
annotated_dist
.metadata
.as_ref()
.expect("metadata is present")
.requires_dist
.iter()
.cloned()
.map(|requirement| requirement.relative_to(root))
.collect::<Result<_, _>>()
.map_err(LockErrorKind::RequirementRelativePath)?,
)
annotated_dist
.metadata
.as_ref()
.expect("metadata is present")
.requires_dist
.iter()
.cloned()
.map(|requirement| requirement.relative_to(root))
.collect::<Result<_, _>>()
.map_err(LockErrorKind::RequirementRelativePath)?
};
let provides_extras = if id.source.is_immutable() {
None
Vec::default()
} else {
Some(
annotated_dist
.metadata
.as_ref()
.expect("metadata is present")
.provides_extras
.clone(),
)
annotated_dist
.metadata
.as_ref()
.expect("metadata is present")
.provides_extras
.clone()
};
let dependency_groups = if id.source.is_immutable() {
BTreeMap::default()
@ -2418,22 +2444,10 @@ impl Package {
{
let mut metadata_table = Table::new();
// Even output the empty list to signal it's *known* empty.
if let Some(provides_extras) = &self.metadata.provides_extras {
let provides_extras = provides_extras
.iter()
.map(|extra| {
serde::Serialize::serialize(&extra, toml_edit::ser::ValueSerializer::new())
})
.collect::<Result<Vec<_>, _>>()?;
// This is just a list of names, so linebreaking it is excessive.
let provides_extras = Array::from_iter(provides_extras);
metadata_table.insert("provides-extras", value(provides_extras));
}
// Even output the empty set to signal it's *known* empty.
if let Some(requires_dist) = &self.metadata.requires_dist {
let requires_dist = requires_dist
if !self.metadata.requires_dist.is_empty() {
let requires_dist = self
.metadata
.requires_dist
.iter()
.map(|requirement| {
serde::Serialize::serialize(
@ -2474,6 +2488,20 @@ impl Package {
}
}
if !self.metadata.provides_extras.is_empty() {
let provides_extras = self
.metadata
.provides_extras
.iter()
.map(|extra| {
serde::Serialize::serialize(&extra, toml_edit::ser::ValueSerializer::new())
})
.collect::<Result<Vec<_>, _>>()?;
// This is just a list of names, so linebreaking it is excessive.
let provides_extras = Array::from_iter(provides_extras);
metadata_table.insert("provides-extras", value(provides_extras));
}
if !metadata_table.is_empty() {
table.insert("metadata", Item::Table(metadata_table));
}
@ -2619,8 +2647,8 @@ impl Package {
}
/// Returns the extras the package provides, if any.
pub fn provides_extras(&self) -> Option<&Vec<ExtraName>> {
self.metadata.provides_extras.as_ref()
pub fn provides_extras(&self) -> &[ExtraName] {
&self.metadata.provides_extras
}
/// Returns the dependency groups the package provides, if any.
@ -2670,12 +2698,10 @@ struct PackageWire {
#[derive(Clone, Default, Debug, Eq, PartialEq, serde::Deserialize)]
#[serde(rename_all = "kebab-case")]
struct PackageMetadata {
// The Options here are so we can distinguish "no info available"
// from "known and empty".
#[serde(default)]
requires_dist: Option<BTreeSet<Requirement>>,
requires_dist: BTreeSet<Requirement>,
#[serde(default)]
provides_extras: Option<Vec<ExtraName>>,
provides_extras: Vec<ExtraName>,
#[serde(default, rename = "requires-dev", alias = "dependency-groups")]
dependency_groups: BTreeMap<GroupName, BTreeSet<Requirement>>,
}