Skip to content

Commit ac1f60b

Browse files
committed
Invalidate lockfile when static versions change
1 parent 4347063 commit ac1f60b

File tree

6 files changed

+542
-108
lines changed

6 files changed

+542
-108
lines changed

crates/uv-distribution/src/metadata/mod.rs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,12 +87,14 @@ impl Metadata {
8787
name: metadata.name,
8888
requires_dist: metadata.requires_dist,
8989
provides_extras: metadata.provides_extras,
90+
dynamic: metadata.dynamic,
9091
};
9192
let RequiresDist {
9293
name,
9394
requires_dist,
9495
provides_extras,
9596
dependency_groups,
97+
dynamic,
9698
} = RequiresDist::from_project_maybe_workspace(
9799
requires_dist,
98100
install_path,
@@ -111,7 +113,7 @@ impl Metadata {
111113
requires_python: metadata.requires_python,
112114
provides_extras,
113115
dependency_groups,
114-
dynamic: metadata.dynamic,
116+
dynamic,
115117
})
116118
}
117119
}

crates/uv-distribution/src/metadata/requires_dist.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ pub struct RequiresDist {
2121
pub requires_dist: Vec<uv_pypi_types::Requirement>,
2222
pub provides_extras: Vec<ExtraName>,
2323
pub dependency_groups: BTreeMap<GroupName, Vec<uv_pypi_types::Requirement>>,
24+
pub dynamic: bool,
2425
}
2526

2627
impl RequiresDist {
@@ -36,6 +37,7 @@ impl RequiresDist {
3637
.collect(),
3738
provides_extras: metadata.provides_extras,
3839
dependency_groups: BTreeMap::default(),
40+
dynamic: metadata.dynamic,
3941
}
4042
}
4143

@@ -245,6 +247,7 @@ impl RequiresDist {
245247
requires_dist,
246248
dependency_groups,
247249
provides_extras: metadata.provides_extras,
250+
dynamic: metadata.dynamic,
248251
})
249252
}
250253

@@ -314,6 +317,7 @@ impl From<Metadata> for RequiresDist {
314317
requires_dist: metadata.requires_dist,
315318
provides_extras: metadata.provides_extras,
316319
dependency_groups: metadata.dependency_groups,
320+
dynamic: metadata.dynamic,
317321
}
318322
}
319323
}

crates/uv-pypi-types/src/metadata/requires_dist.rs

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ pub struct RequiresDist {
2121
pub name: PackageName,
2222
pub requires_dist: Vec<Requirement<VerbatimParsedUrl>>,
2323
pub provides_extras: Vec<ExtraName>,
24+
pub dynamic: bool,
2425
}
2526

2627
impl RequiresDist {
@@ -34,13 +35,16 @@ impl RequiresDist {
3435

3536
// If any of the fields we need were declared as dynamic, we can't use the `pyproject.toml`
3637
// file.
37-
let dynamic = project.dynamic.unwrap_or_default();
38-
for field in dynamic {
38+
let mut dynamic = false;
39+
for field in project.dynamic.unwrap_or_default() {
3940
match field.as_str() {
4041
"dependencies" => return Err(MetadataError::DynamicField("dependencies")),
4142
"optional-dependencies" => {
4243
return Err(MetadataError::DynamicField("optional-dependencies"))
4344
}
45+
"version" => {
46+
dynamic = true;
47+
}
4448
_ => (),
4549
}
4650
}
@@ -83,6 +87,7 @@ impl RequiresDist {
8387
name,
8488
requires_dist,
8589
provides_extras,
90+
dynamic,
8691
})
8792
}
8893
}

crates/uv-resolver/src/lock/mod.rs

Lines changed: 148 additions & 90 deletions
Original file line numberDiff line numberDiff line change
@@ -1067,32 +1067,17 @@ impl Lock {
10671067
}
10681068
}
10691069

1070-
// Validate that the member sources have not changed.
1071-
{
1072-
// E.g., that they've switched from virtual to non-virtual or vice versa.
1073-
for (name, member) in packages {
1074-
let expected = !member.pyproject_toml().is_package();
1075-
let actual = self
1076-
.find_by_name(name)
1077-
.ok()
1078-
.flatten()
1079-
.map(|package| matches!(package.id.source, Source::Virtual(_)));
1080-
if actual != Some(expected) {
1081-
return Ok(SatisfiesResult::MismatchedVirtual(name.clone(), expected));
1082-
}
1083-
}
1084-
1085-
// E.g., that they've switched from dynamic to non-dynamic or vice versa.
1086-
for (name, member) in packages {
1087-
let expected = member.pyproject_toml().is_dynamic();
1088-
let actual = self
1089-
.find_by_name(name)
1090-
.ok()
1091-
.flatten()
1092-
.map(Package::is_dynamic);
1093-
if actual != Some(expected) {
1094-
return Ok(SatisfiesResult::MismatchedDynamic(name.clone(), expected));
1095-
}
1070+
// Validate that the member sources have not changed (e.g., that they've switched from
1071+
// virtual to non-virtual or vice versa).
1072+
for (name, member) in packages {
1073+
let expected = !member.pyproject_toml().is_package();
1074+
let actual = self
1075+
.find_by_name(name)
1076+
.ok()
1077+
.flatten()
1078+
.map(|package| matches!(package.id.source, Source::Virtual(_)));
1079+
if actual != Some(expected) {
1080+
return Ok(SatisfiesResult::MismatchedVirtual(name.clone(), expected));
10961081
}
10971082
}
10981083

@@ -1287,60 +1272,10 @@ impl Lock {
12871272
continue;
12881273
}
12891274

1290-
// Fetch the metadata for the distribution.
1291-
//
1292-
// If the distribution is a source tree, attempt to extract the requirements from the
1293-
// `pyproject.toml` directly. The distribution database will do this too, but we can be
1294-
// even more aggressive here since we _only_ need the requirements. So, for example,
1295-
// even if the version is dynamic, we can still extract the requirements without
1296-
// performing a build, unlike in the database where we typically construct a "complete"
1297-
// metadata object.
1298-
let metadata = if let Some(source_tree) = package.id.source.as_source_tree() {
1299-
database
1300-
.requires_dist(root.join(source_tree))
1301-
.await
1302-
.map_err(|err| LockErrorKind::Resolution {
1303-
id: package.id.clone(),
1304-
err,
1305-
})?
1306-
} else {
1307-
None
1308-
};
1309-
1310-
let satisfied = metadata.is_some_and(|metadata| {
1311-
match satisfies_requires_dist(metadata, package, root) {
1312-
Ok(SatisfiesResult::Satisfied) => {
1313-
debug!("Static `requires-dist` for `{}` is up-to-date", package.id);
1314-
true
1315-
},
1316-
Ok(..) => {
1317-
debug!("Static `requires-dist` for `{}` is out-of-date; falling back to distribution database", package.id);
1318-
false
1319-
},
1320-
Err(..) => {
1321-
debug!("Static `requires-dist` for `{}` is invalid; falling back to distribution database", package.id);
1322-
false
1323-
},
1324-
}
1325-
});
1326-
1327-
// If the `requires-dist` metadata matches the requirements, we're done; otherwise,
1328-
// fetch the "full" metadata, which may involve invoking the build system. In some
1329-
// cases, build backends return metadata that does _not_ match the `pyproject.toml`
1330-
// exactly. For example, `hatchling` will flatten any recursive (or self-referential)
1331-
// extras, while `setuptools` will not.
1332-
if !satisfied {
1333-
// Get the metadata for the distribution.
1334-
let dist = package.to_dist(
1335-
root,
1336-
// When validating, it's okay to use wheels that don't match the current platform.
1337-
TagPolicy::Preferred(tags),
1338-
// When validating, it's okay to use (e.g.) a source distribution with `--no-build`.
1339-
// We're just trying to determine whether the lockfile is up-to-date. If we end
1340-
// up needing to build a source distribution in order to do so, below, we'll error
1341-
// there.
1342-
&BuildOptions::default(),
1343-
)?;
1275+
if let Some(version) = package.id.version.as_ref() {
1276+
// For a non-dynamic package, fetch the metadata from the distribution database.
1277+
let dist =
1278+
package.to_dist(root, TagPolicy::Preferred(tags), &BuildOptions::default())?;
13441279

13451280
let metadata = {
13461281
let id = dist.version_id();
@@ -1380,10 +1315,139 @@ impl Lock {
13801315
}
13811316
};
13821317

1318+
// If this is a local package, validate that it hasn't become dynamic (in which
1319+
// case, we'd expect the version to be omitted).
1320+
if package.id.source.is_source_tree() {
1321+
if metadata.dynamic {
1322+
return Ok(SatisfiesResult::MismatchedDynamic(
1323+
package.id.name.clone(),
1324+
false,
1325+
));
1326+
}
1327+
}
1328+
1329+
// Validate the `version` metadata.
1330+
if metadata.version != *version {
1331+
return Ok(SatisfiesResult::MismatchedVersion(
1332+
package.id.name.clone(),
1333+
version.clone(),
1334+
Some(metadata.version.clone()),
1335+
));
1336+
}
1337+
1338+
// Validate that the requirements are unchanged.
13831339
match satisfies_requires_dist(RequiresDist::from(metadata), package, root)? {
13841340
SatisfiesResult::Satisfied => {}
13851341
result => return Ok(result),
13861342
}
1343+
} else if let Some(source_tree) = package.id.source.as_source_tree() {
1344+
// For dynamic packages, we don't need the version. We only need to know that the
1345+
// package is still dynamic, and that the requirements are unchanged.
1346+
//
1347+
// If the distribution is a source tree, attempt to extract the requirements from the
1348+
// `pyproject.toml` directly. The distribution database will do this too, but we can be
1349+
// even more aggressive here since we _only_ need the requirements. So, for example,
1350+
// even if the version is dynamic, we can still extract the requirements without
1351+
// performing a build, unlike in the database where we typically construct a "complete"
1352+
// metadata object.
1353+
let metadata = database
1354+
.requires_dist(root.join(source_tree))
1355+
.await
1356+
.map_err(|err| LockErrorKind::Resolution {
1357+
id: package.id.clone(),
1358+
err,
1359+
})?;
1360+
1361+
let satisfied = metadata.is_some_and(|metadata| {
1362+
// Validate that the package is still dynamic.
1363+
if !metadata.dynamic {
1364+
debug!("Static `requires-dist` for `{}` is out-of-date; falling back to distribution database", package.id);
1365+
return false;
1366+
}
1367+
1368+
// Validate that the requirements are unchanged.
1369+
match satisfies_requires_dist(metadata, package, root) {
1370+
Ok(SatisfiesResult::Satisfied) => {
1371+
debug!("Static `requires-dist` for `{}` is up-to-date", package.id);
1372+
true
1373+
},
1374+
Ok(..) => {
1375+
debug!("Static `requires-dist` for `{}` is out-of-date; falling back to distribution database", package.id);
1376+
false
1377+
},
1378+
Err(..) => {
1379+
debug!("Static `requires-dist` for `{}` is invalid; falling back to distribution database", package.id);
1380+
false
1381+
},
1382+
}
1383+
});
1384+
1385+
// If the `requires-dist` metadata matches the requirements, we're done; otherwise,
1386+
// fetch the "full" metadata, which may involve invoking the build system. In some
1387+
// cases, build backends return metadata that does _not_ match the `pyproject.toml`
1388+
// exactly. For example, `hatchling` will flatten any recursive (or self-referential)
1389+
// extras, while `setuptools` will not.
1390+
if !satisfied {
1391+
let dist = package.to_dist(
1392+
root,
1393+
TagPolicy::Preferred(tags),
1394+
&BuildOptions::default(),
1395+
)?;
1396+
1397+
let metadata = {
1398+
let id = dist.version_id();
1399+
if let Some(archive) =
1400+
index
1401+
.distributions()
1402+
.get(&id)
1403+
.as_deref()
1404+
.and_then(|response| {
1405+
if let MetadataResponse::Found(archive, ..) = response {
1406+
Some(archive)
1407+
} else {
1408+
None
1409+
}
1410+
})
1411+
{
1412+
// If the metadata is already in the index, return it.
1413+
archive.metadata.clone()
1414+
} else {
1415+
// Run the PEP 517 build process to extract metadata from the source distribution.
1416+
let archive = database
1417+
.get_or_build_wheel_metadata(&dist, hasher.get(&dist))
1418+
.await
1419+
.map_err(|err| LockErrorKind::Resolution {
1420+
id: package.id.clone(),
1421+
err,
1422+
})?;
1423+
1424+
let metadata = archive.metadata.clone();
1425+
1426+
// Insert the metadata into the index.
1427+
index
1428+
.distributions()
1429+
.done(id, Arc::new(MetadataResponse::Found(archive)));
1430+
1431+
metadata
1432+
}
1433+
};
1434+
1435+
// Validate that the package is still dynamic.
1436+
if !metadata.dynamic {
1437+
return Ok(SatisfiesResult::MismatchedDynamic(
1438+
package.id.name.clone(),
1439+
true,
1440+
));
1441+
}
1442+
1443+
// Validate that the requirements are unchanged.
1444+
match satisfies_requires_dist(RequiresDist::from(metadata), package, root)? {
1445+
SatisfiesResult::Satisfied => {}
1446+
result => return Ok(result),
1447+
}
1448+
}
1449+
} else {
1450+
return Ok(SatisfiesResult::MissingVersion(package.id.name.clone()));
13871451
}
13881452

13891453
// Recurse.
@@ -1446,7 +1510,7 @@ pub enum SatisfiesResult<'lock> {
14461510
MismatchedMembers(BTreeSet<PackageName>, &'lock BTreeSet<PackageName>),
14471511
/// A workspace member switched from virtual to non-virtual or vice versa.
14481512
MismatchedVirtual(PackageName, bool),
1449-
/// A workspace member switched from dynamic to non-dynamic or vice versa.
1513+
/// A source tree switched from dynamic to non-dynamic or vice versa.
14501514
MismatchedDynamic(PackageName, bool),
14511515
/// The lockfile uses a different set of version for its workspace members.
14521516
MismatchedVersion(PackageName, Version, Option<Version>),
@@ -1483,6 +1547,8 @@ pub enum SatisfiesResult<'lock> {
14831547
BTreeMap<GroupName, BTreeSet<Requirement>>,
14841548
BTreeMap<GroupName, BTreeSet<Requirement>>,
14851549
),
1550+
/// The lockfile is missing a version.
1551+
MissingVersion(PackageName),
14861552
}
14871553

14881554
/// We discard the lockfile if these options match.
@@ -4131,15 +4197,7 @@ fn normalize_url(mut url: Url) -> UrlString {
41314197
/// 2. Ensures that the lock and install paths are appropriately framed with respect to the
41324198
/// current [`Workspace`].
41334199
/// 3. Removes the `origin` field, which is only used in `requirements.txt`.
4134-
fn normalize_requirement(
4135-
mut requirement: Requirement,
4136-
root: &Path,
4137-
) -> Result<Requirement, LockError> {
4138-
// Sort the extras and groups for consistency.
4139-
requirement.extras.sort();
4140-
requirement.groups.sort();
4141-
4142-
// Normalize the requirement source.
4200+
fn normalize_requirement(requirement: Requirement, root: &Path) -> Result<Requirement, LockError> {
41434201
match requirement.source {
41444202
RequirementSource::Git {
41454203
mut repository,

0 commit comments

Comments
 (0)