diff --git a/crates/uv-requirements/src/upgrade.rs b/crates/uv-requirements/src/upgrade.rs index fe4223a5b..669f566c5 100644 --- a/crates/uv-requirements/src/upgrade.rs +++ b/crates/uv-requirements/src/upgrade.rs @@ -63,11 +63,13 @@ pub async fn read_requirements_txt( }) } -/// Load the preferred requirements from an existing lockfile, applying the upgrade strategy. -pub async fn read_lockfile(workspace: &Workspace, upgrade: &Upgrade) -> Result { +/// Load the lockfile from the workspace. +/// +/// Returns `Ok(None)` if the lockfile does not exist, is invalid, or is not required for the given upgrade strategy. +pub async fn read_lockfile(workspace: &Workspace, upgrade: &Upgrade) -> Result> { // As an optimization, skip reading the lockfile is we're upgrading all packages anyway. if upgrade.is_all() { - return Ok(LockedRequirements::default()); + return Ok(None); } // If an existing lockfile exists, build up a set of preferences. @@ -77,15 +79,20 @@ pub async fn read_lockfile(workspace: &Workspace, upgrade: &Upgrade) -> Result lock, Err(err) => { eprint!("Failed to parse lockfile; ignoring locked requirements: {err}"); - return Ok(LockedRequirements::default()); + return Ok(None); } }, Err(err) if err.kind() == std::io::ErrorKind::NotFound => { - return Ok(LockedRequirements::default()); + return Ok(None); } Err(err) => return Err(err.into()), }; + Ok(Some(lock)) +} + +/// Load the preferred requirements from an existing lockfile, applying the upgrade strategy. +pub fn read_lock_requirements(lock: &Lock, upgrade: &Upgrade) -> LockedRequirements { let mut preferences = Vec::new(); let mut git = Vec::new(); @@ -108,5 +115,5 @@ pub async fn read_lockfile(workspace: &Workspace, upgrade: &Upgrade) -> Result for Lock { } } -#[derive(Clone, Debug)] +#[derive(Clone, Debug, PartialEq, Eq)] pub struct Distribution { pub(crate) id: DistributionId, sdist: Option, @@ -1321,12 +1321,15 @@ enum SourceWire { subdirectory: Option, }, Path { + #[serde(deserialize_with = "deserialize_path_with_dot")] path: PathBuf, }, Directory { + #[serde(deserialize_with = "deserialize_path_with_dot")] directory: PathBuf, }, Editable { + #[serde(deserialize_with = "deserialize_path_with_dot")] editable: PathBuf, }, } @@ -1430,7 +1433,7 @@ enum GitSourceKind { } /// Inspired by: -#[derive(Clone, Debug, serde::Deserialize)] +#[derive(Clone, Debug, serde::Deserialize, PartialEq, Eq)] struct SourceDistMetadata { /// A hash of the source distribution. hash: Hash, @@ -1444,7 +1447,7 @@ struct SourceDistMetadata { /// locked against was found. The location does not need to exist in the /// future, so this should be treated as only a hint to where to look /// and/or recording where the source dist file originally came from. -#[derive(Clone, Debug, serde::Deserialize)] +#[derive(Clone, Debug, serde::Deserialize, PartialEq, Eq)] #[serde(untagged)] enum SourceDist { Url { @@ -1695,7 +1698,7 @@ fn locked_git_url(git_dist: &GitSourceDist) -> Url { } /// Inspired by: -#[derive(Clone, Debug, serde::Deserialize)] +#[derive(Clone, Debug, serde::Deserialize, PartialEq, Eq)] #[serde(try_from = "WheelWire")] struct Wheel { /// A URL or file path (via `file://`) where the wheel that was locked @@ -2047,7 +2050,7 @@ impl From for DependencyWire { /// /// A hash is encoded as a single TOML string in the format /// `{algorithm}:{digest}`. -#[derive(Clone, Debug)] +#[derive(Clone, Debug, PartialEq, Eq)] struct Hash(HashDigest); impl From for Hash { diff --git a/crates/uv/src/commands/project/lock.rs b/crates/uv/src/commands/project/lock.rs index 8520e23a0..678ae7282 100644 --- a/crates/uv/src/commands/project/lock.rs +++ b/crates/uv/src/commands/project/lock.rs @@ -8,7 +8,7 @@ use uv_dispatch::BuildDispatch; use uv_distribution::{Workspace, DEV_DEPENDENCIES}; use uv_git::ResolvedRepositoryReference; use uv_python::{Interpreter, PythonFetch, PythonPreference, PythonRequest}; -use uv_requirements::upgrade::{read_lockfile, LockedRequirements}; +use uv_requirements::upgrade::{read_lock_requirements, read_lockfile, LockedRequirements}; use uv_resolver::{FlatIndex, Lock, OptionsBuilder, PythonRequirement, RequiresPython}; use uv_types::{BuildIsolation, EmptyInstalledPackages, HashStrategy}; use uv_warnings::{warn_user, warn_user_once}; @@ -175,7 +175,11 @@ pub(super) async fn do_lock( }; // If an existing lockfile exists, build up a set of preferences. - let LockedRequirements { preferences, git } = read_lockfile(workspace, upgrade).await?; + let existing_lock = read_lockfile(workspace, upgrade).await?; + let LockedRequirements { preferences, git } = existing_lock + .as_ref() + .map(|lock| read_lock_requirements(lock, upgrade)) + .unwrap_or_default(); // Populate the Git resolver. for ResolvedRepositoryReference { reference, sha } in git { @@ -234,8 +238,13 @@ pub(super) async fn do_lock( // Notify the user of any resolution diagnostics. pip::operations::diagnose_resolution(resolution.diagnostics(), printer)?; - // Write the lockfile to disk. + // Avoid serializing and writing to disk if the lock hasn't changed. let lock = Lock::from_resolution_graph(&resolution)?; + if existing_lock.is_some_and(|existing_lock| existing_lock == lock) { + return Ok(lock); + } + + // Write the lockfile to disk. let encoded = lock.to_toml()?; fs_err::tokio::write(workspace.install_path().join("uv.lock"), encoded.as_bytes()).await?;