diff --git a/crates/uv-distribution/src/metadata/mod.rs b/crates/uv-distribution/src/metadata/mod.rs index f470753424a29..1141342d02d3b 100644 --- a/crates/uv-distribution/src/metadata/mod.rs +++ b/crates/uv-distribution/src/metadata/mod.rs @@ -87,12 +87,14 @@ impl Metadata { name: metadata.name, requires_dist: metadata.requires_dist, provides_extras: metadata.provides_extras, + dynamic: metadata.dynamic, }; let RequiresDist { name, requires_dist, provides_extras, dependency_groups, + dynamic, } = RequiresDist::from_project_maybe_workspace( requires_dist, install_path, @@ -111,7 +113,7 @@ impl Metadata { requires_python: metadata.requires_python, provides_extras, dependency_groups, - dynamic: metadata.dynamic, + dynamic, }) } } diff --git a/crates/uv-distribution/src/metadata/requires_dist.rs b/crates/uv-distribution/src/metadata/requires_dist.rs index 4013f5b3692ab..3315a93d940d0 100644 --- a/crates/uv-distribution/src/metadata/requires_dist.rs +++ b/crates/uv-distribution/src/metadata/requires_dist.rs @@ -21,6 +21,7 @@ pub struct RequiresDist { pub requires_dist: Vec, pub provides_extras: Vec, pub dependency_groups: BTreeMap>, + pub dynamic: bool, } impl RequiresDist { @@ -36,6 +37,7 @@ impl RequiresDist { .collect(), provides_extras: metadata.provides_extras, dependency_groups: BTreeMap::default(), + dynamic: metadata.dynamic, } } @@ -245,6 +247,7 @@ impl RequiresDist { requires_dist, dependency_groups, provides_extras: metadata.provides_extras, + dynamic: metadata.dynamic, }) } @@ -314,6 +317,7 @@ impl From for RequiresDist { requires_dist: metadata.requires_dist, provides_extras: metadata.provides_extras, dependency_groups: metadata.dependency_groups, + dynamic: metadata.dynamic, } } } diff --git a/crates/uv-pypi-types/src/metadata/requires_dist.rs b/crates/uv-pypi-types/src/metadata/requires_dist.rs index 5391b6018b4b0..fe80a86324528 100644 --- a/crates/uv-pypi-types/src/metadata/requires_dist.rs +++ b/crates/uv-pypi-types/src/metadata/requires_dist.rs @@ -21,6 +21,7 @@ pub struct RequiresDist { pub name: PackageName, pub requires_dist: Vec>, pub provides_extras: Vec, + pub dynamic: bool, } impl RequiresDist { @@ -34,13 +35,16 @@ impl RequiresDist { // 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" => { return Err(MetadataError::DynamicField("optional-dependencies")) } + "version" => { + dynamic = true; + } _ => (), } } @@ -83,6 +87,7 @@ impl RequiresDist { name, requires_dist, provides_extras, + dynamic, }) } } diff --git a/crates/uv-resolver/src/lock/mod.rs b/crates/uv-resolver/src/lock/mod.rs index 82ead06071399..cd40d920ff305 100644 --- a/crates/uv-resolver/src/lock/mod.rs +++ b/crates/uv-resolver/src/lock/mod.rs @@ -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, &'lock BTreeSet), /// 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), @@ -1483,6 +1547,8 @@ pub enum SatisfiesResult<'lock> { BTreeMap>, BTreeMap>, ), + /// The lockfile is missing a version. + MissingVersion(PackageName), } /// We discard the lockfile if these options match. diff --git a/crates/uv/src/commands/project/lock.rs b/crates/uv/src/commands/project/lock.rs index 7e3a3c0ed5563..9ec5cf2886b7a 100644 --- a/crates/uv/src/commands/project/lock.rs +++ b/crates/uv/src/commands/project/lock.rs @@ -1046,6 +1046,10 @@ impl ValidatedLock { } Ok(Self::Preferable(lock)) } + SatisfiesResult::MissingVersion(name) => { + debug!("Ignoring existing lockfile due to missing version: `{name}`"); + Ok(Self::Preferable(lock)) + } } } @@ -1070,14 +1074,14 @@ fn report_upgrades( printer: Printer, dry_run: bool, ) -> anyhow::Result { - let existing_packages: FxHashMap<&PackageName, BTreeSet<&Version>> = + let existing_packages: FxHashMap<&PackageName, BTreeSet>> = if let Some(existing_lock) = existing_lock { existing_lock.packages().iter().fold( FxHashMap::with_capacity_and_hasher(existing_lock.packages().len(), FxBuildHasher), |mut acc, package| { - if let Some(version) = package.version() { - acc.entry(package.name()).or_default().insert(version); - } + acc.entry(package.name()) + .or_default() + .insert(package.version()); acc }, ) @@ -1085,13 +1089,13 @@ fn report_upgrades( FxHashMap::default() }; - let new_distributions: FxHashMap<&PackageName, BTreeSet<&Version>> = + let new_distributions: FxHashMap<&PackageName, BTreeSet>> = new_lock.packages().iter().fold( FxHashMap::with_capacity_and_hasher(new_lock.packages().len(), FxBuildHasher), |mut acc, package| { - if let Some(version) = package.version() { - acc.entry(package.name()).or_default().insert(version); - } + acc.entry(package.name()) + .or_default() + .insert(package.version()); acc }, ); @@ -1102,18 +1106,25 @@ fn report_upgrades( .chain(new_distributions.keys()) .collect::>() { + /// Format a version for inclusion in the upgrade report. + fn format_version(version: Option<&Version>) -> String { + version + .map(|version| format!("v{version}")) + .unwrap_or_else(|| "(dynamic)".to_string()) + } + updated = true; match (existing_packages.get(name), new_distributions.get(name)) { (Some(existing_versions), Some(new_versions)) => { if existing_versions != new_versions { let existing_versions = existing_versions .iter() - .map(|version| format!("v{version}")) + .map(|version| format_version(*version)) .collect::>() .join(", "); let new_versions = new_versions .iter() - .map(|version| format!("v{version}")) + .map(|version| format_version(*version)) .collect::>() .join(", "); writeln!( @@ -1126,7 +1137,7 @@ fn report_upgrades( (Some(existing_versions), None) => { let existing_versions = existing_versions .iter() - .map(|version| format!("v{version}")) + .map(|version| format_version(*version)) .collect::>() .join(", "); writeln!( @@ -1138,7 +1149,7 @@ fn report_upgrades( (None, Some(new_versions)) => { let new_versions = new_versions .iter() - .map(|version| format!("v{version}")) + .map(|version| format_version(*version)) .collect::>() .join(", "); writeln!( diff --git a/crates/uv/tests/it/lock.rs b/crates/uv/tests/it/lock.rs index 784aaf8080579..6d4a2625fece7 100644 --- a/crates/uv/tests/it/lock.rs +++ b/crates/uv/tests/it/lock.rs @@ -15128,9 +15128,6 @@ fn lock_explicit_default_index() -> Result<()> { DEBUG Using Python request `>=3.12` from `requires-python` metadata DEBUG The virtual environment's Python version satisfies `>=3.12` DEBUG Using request timeout of [TIME] - DEBUG Found static `requires-dist` for: [TEMP_DIR]/ - DEBUG No workspace root found, using project root - DEBUG Static `requires-dist` for `project==0.1.0 @ editable+.` is out-of-date; falling back to distribution database 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 requirements for: `project==0.1.0` @@ -20949,6 +20946,363 @@ fn lock_dynamic_version_self_extra_setuptools() -> Result<()> { Ok(()) } +/// Re-lock after converting a package from dynamic to static. +#[test] +fn lock_dynamic_to_static() -> Result<()> { + let context = TestContext::new("3.12"); + + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str( + r#" + [project] + name = "project" + requires-python = ">=3.12" + dynamic = ["version"] + dependencies = [] + + [build-system] + requires = ["setuptools"] + build-backend = "setuptools.build_meta" + + [tool.uv] + cache-keys = [{ file = "pyproject.toml" }, { file = "src/project/__init__.py" }] + + [tool.setuptools.dynamic] + version = { attr = "project.__version__" } + + [tool.setuptools] + package-dir = { "" = "src" } + + [tool.setuptools.packages.find] + where = ["src"] + "#, + )?; + + context + .temp_dir + .child("src") + .child("project") + .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] + "###); + + 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 = "." } + "### + ); + }); + + // Remove the dynamic version (but leave the version unchanged). + pyproject_toml.write_str( + r#" + [project] + name = "project" + requires-python = ">=3.12" + version = "0.1.0" + dependencies = [] + + [build-system] + requires = ["setuptools"] + build-backend = "setuptools.build_meta" + "#, + )?; + + // Rerunning with `--locked` should fail, since the project is no longer dynamic. + 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`. + "###); + + uv_snapshot!(context.filters(), context.lock(), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 1 package in [TIME] + Updated project (dynamic) -> v0.1.0 + "###); + + 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" + version = "0.1.0" + source = { editable = "." } + "### + ); + }); + + Ok(()) +} + +/// Re-lock after converting a package from static to dynamic. +#[test] +fn lock_static_to_dynamic() -> Result<()> { + let context = TestContext::new("3.12"); + + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str( + r#" + [project] + name = "project" + requires-python = ">=3.12" + version = "0.1.0" + dependencies = [] + + [build-system] + requires = ["setuptools"] + build-backend = "setuptools.build_meta" + "#, + )?; + + uv_snapshot!(context.filters(), context.lock(), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 1 package 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 = "project" + version = "0.1.0" + source = { editable = "." } + "### + ); + }); + + // Make the version dynamic (but leave the value unchanged). + pyproject_toml.write_str( + r#" + [project] + name = "project" + requires-python = ">=3.12" + dynamic = ["version"] + dependencies = [] + + [build-system] + requires = ["setuptools"] + build-backend = "setuptools.build_meta" + + [tool.uv] + cache-keys = [{ file = "pyproject.toml" }, { file = "src/project/__init__.py" }] + + [tool.setuptools.dynamic] + version = { attr = "project.__version__" } + + [tool.setuptools] + package-dir = { "" = "src" } + + [tool.setuptools.packages.find] + where = ["src"] + "#, + )?; + + context + .temp_dir + .child("src") + .child("project") + .child("__init__.py") + .write_str("__version__ = '0.1.0'")?; + + // Rerunning with `--locked` should fail, since the project is no longer static. + 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`. + "###); + + uv_snapshot!(context.filters(), context.lock(), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 1 package in [TIME] + Updated project v0.1.0 -> (dynamic) + "###); + + 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(()) +} + +#[test] +fn lock_bump_static_version() -> Result<()> { + let context = TestContext::new("3.12"); + + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str( + r#" + [project] + name = "project" + requires-python = ">=3.12" + version = "0.1.0" + dependencies = [] + "#, + )?; + + uv_snapshot!(context.filters(), context.lock(), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 1 package 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 = "project" + version = "0.1.0" + source = { virtual = "." } + "### + ); + }); + + // Bump the version. + pyproject_toml.write_str( + r#" + [project] + name = "project" + requires-python = ">=3.12" + version = "0.2.0" + dependencies = [] + "#, + )?; + + // Rerunning with `--locked` should fail. + 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`. + "###); + + 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.2.0 + "###); + + 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" + version = "0.2.0" + source = { virtual = "." } + "### + ); + }); + + Ok(()) +} + #[test] fn lock_derivation_chain_prod() -> Result<()> { let context = TestContext::new("3.12");