diff --git a/crates/uv-resolver/src/lib.rs b/crates/uv-resolver/src/lib.rs index 4d60b9f95cace..eb9c6a62e0a1a 100644 --- a/crates/uv-resolver/src/lib.rs +++ b/crates/uv-resolver/src/lib.rs @@ -4,7 +4,8 @@ pub use exclude_newer::ExcludeNewer; pub use exclusions::Exclusions; pub use flat_index::{FlatDistributions, FlatIndex}; pub use lock::{ - Lock, LockError, RequirementsTxtExport, ResolverManifest, SatisfiesResult, TreeDisplay, + Lock, LockError, LockVersion, RequirementsTxtExport, ResolverManifest, SatisfiesResult, + TreeDisplay, VERSION, }; pub use manifest::Manifest; pub use options::{Flexibility, Options, OptionsBuilder}; diff --git a/crates/uv-resolver/src/lock/mod.rs b/crates/uv-resolver/src/lock/mod.rs index 3ab114d7aa38c..f205505c805d8 100644 --- a/crates/uv-resolver/src/lock/mod.rs +++ b/crates/uv-resolver/src/lock/mod.rs @@ -50,7 +50,7 @@ mod requirements_txt; mod tree; /// The current version of the lockfile format. -const VERSION: u32 = 1; +pub const VERSION: u32 = 1; static LINUX_MARKERS: LazyLock = LazyLock::new(|| { MarkerTree::from_str( @@ -494,6 +494,11 @@ impl Lock { self } + /// Returns the lockfile version. + pub fn version(&self) -> u32 { + self.version + } + /// Returns the number of packages in the lockfile. pub fn len(&self) -> usize { self.packages.len() @@ -1509,6 +1514,21 @@ impl TryFrom for Lock { } } +/// Like [`Lock`], but limited to the version field. Used for error reporting: by limiting parsing +/// to the version field, we can verify compatibility for lockfiles that may otherwise be +/// unparseable. +#[derive(Clone, Debug, serde::Deserialize)] +pub struct LockVersion { + version: u32, +} + +impl LockVersion { + /// Returns the lockfile version. + pub fn version(&self) -> u32 { + self.version + } +} + #[derive(Clone, Debug, PartialEq, Eq)] pub struct Package { pub(crate) id: PackageId, diff --git a/crates/uv/src/commands/project/add.rs b/crates/uv/src/commands/project/add.rs index a25f09ab66290..396873ee04c76 100644 --- a/crates/uv/src/commands/project/add.rs +++ b/crates/uv/src/commands/project/add.rs @@ -556,8 +556,8 @@ pub(crate) async fn add( // Update the `pypackage.toml` in-memory. let project = project - .with_pyproject_toml(toml::from_str(&content).map_err(ProjectError::TomlParse)?) - .ok_or(ProjectError::TomlUpdate)?; + .with_pyproject_toml(toml::from_str(&content).map_err(ProjectError::PyprojectTomlParse)?) + .ok_or(ProjectError::PyprojectTomlUpdate)?; // Set the Ctrl-C handler to revert changes on exit. let _ = ctrlc::set_handler({ @@ -758,8 +758,10 @@ async fn lock_and_sync( // Update the `pypackage.toml` in-memory. project = project - .with_pyproject_toml(toml::from_str(&content).map_err(ProjectError::TomlParse)?) - .ok_or(ProjectError::TomlUpdate)?; + .with_pyproject_toml( + toml::from_str(&content).map_err(ProjectError::PyprojectTomlParse)?, + ) + .ok_or(ProjectError::PyprojectTomlUpdate)?; // Invalidate the project metadata. if let VirtualProject::Project(ref project) = project { diff --git a/crates/uv/src/commands/project/lock.rs b/crates/uv/src/commands/project/lock.rs index c80bd6726fec4..5e74f8673ed6c 100644 --- a/crates/uv/src/commands/project/lock.rs +++ b/crates/uv/src/commands/project/lock.rs @@ -4,7 +4,6 @@ use std::collections::BTreeSet; use std::fmt::Write; use std::path::Path; -use anstream::eprint; use owo_colors::OwoColorize; use rustc_hash::{FxBuildHasher, FxHashMap}; use tracing::debug; @@ -28,8 +27,8 @@ use uv_python::{Interpreter, PythonDownloads, PythonEnvironment, PythonPreferenc use uv_requirements::upgrade::{read_lock_requirements, LockedRequirements}; use uv_requirements::ExtrasResolver; use uv_resolver::{ - FlatIndex, InMemoryIndex, Lock, Options, OptionsBuilder, PythonRequirement, RequiresPython, - ResolverManifest, ResolverMarkers, SatisfiesResult, + FlatIndex, InMemoryIndex, Lock, LockVersion, Options, OptionsBuilder, PythonRequirement, + RequiresPython, ResolverManifest, ResolverMarkers, SatisfiesResult, VERSION, }; use uv_types::{BuildContext, BuildIsolation, EmptyInstalledPackages, HashStrategy}; use uv_warnings::{warn_user, warn_user_once}; @@ -204,7 +203,15 @@ pub(super) async fn do_safe_lock( Ok(result) } else { // Read the existing lockfile. - let existing = read(workspace).await?; + let existing = match read(workspace).await { + Ok(Some(existing)) => Some(existing), + Ok(None) => None, + Err(ProjectError::Lock(err)) => { + warn_user!("Failed to read existing lockfile; ignoring locked requirements: {err}"); + None + } + Err(err) => return Err(err), + }; // Perform the lock operation. let result = do_lock( @@ -903,13 +910,34 @@ async fn commit(lock: &Lock, workspace: &Workspace) -> Result<(), ProjectError> /// Returns `Ok(None)` if the lockfile does not exist. pub(crate) async fn read(workspace: &Workspace) -> Result, ProjectError> { match fs_err::tokio::read_to_string(&workspace.install_path().join("uv.lock")).await { - Ok(encoded) => match toml::from_str(&encoded) { - Ok(lock) => Ok(Some(lock)), - Err(err) => { - eprint!("Failed to parse lockfile; ignoring locked requirements: {err}"); - Ok(None) + Ok(encoded) => { + match toml::from_str::(&encoded) { + Ok(lock) => { + // If the lockfile uses an unsupported version, raise an error. + if lock.version() != VERSION { + return Err(ProjectError::UnsupportedLockVersion( + VERSION, + lock.version(), + )); + } + Ok(Some(lock)) + } + Err(err) => { + // If we failed to parse the lockfile, determine whether it's a supported + // version. + if let Ok(lock) = toml::from_str::(&encoded) { + if lock.version() != VERSION { + return Err(ProjectError::UnparsableLockVersion( + VERSION, + lock.version(), + err, + )); + } + } + Err(ProjectError::UvLockParse(err)) + } } - }, + } Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(None), Err(err) => Err(err.into()), } diff --git a/crates/uv/src/commands/project/mod.rs b/crates/uv/src/commands/project/mod.rs index ed6fd4a01ab59..7b0caf2429017 100644 --- a/crates/uv/src/commands/project/mod.rs +++ b/crates/uv/src/commands/project/mod.rs @@ -64,6 +64,12 @@ pub(crate) enum ProjectError { )] MissingLockfile, + #[error("The lockfile at `uv.lock` uses an unsupported schema version (v{1}, but only v{0} is supported). Downgrade to a compatible uv version, or remove the `uv.lock` prior to running `uv lock` or `uv sync`.")] + UnsupportedLockVersion(u32, u32), + + #[error("Failed to parse `uv.lock`, which uses an unsupported schema version (v{1}, but only v{0} is supported). Downgrade to a compatible uv version, or remove the `uv.lock` prior to running `uv lock` or `uv sync`.")] + UnparsableLockVersion(u32, u32, #[source] toml::de::Error), + #[error("The current Python version ({0}) is not compatible with the locked Python requirement: `{1}`")] LockedPythonIncompatibility(Version, RequiresPython), @@ -128,11 +134,14 @@ pub(crate) enum ProjectError { #[error("Project virtual environment directory `{0}` cannot be used because {1}")] InvalidProjectEnvironmentDir(PathBuf, String), + #[error("Failed to parse `uv.lock`")] + UvLockParse(#[source] toml::de::Error), + #[error("Failed to parse `pyproject.toml`")] - TomlParse(#[source] toml::de::Error), + PyprojectTomlParse(#[source] toml::de::Error), #[error("Failed to update `pyproject.toml`")] - TomlUpdate, + PyprojectTomlUpdate, #[error(transparent)] Python(#[from] uv_python::Error), diff --git a/crates/uv/tests/it/lock.rs b/crates/uv/tests/it/lock.rs index 9c0dd87462cc1..4727eab0216a0 100644 --- a/crates/uv/tests/it/lock.rs +++ b/crates/uv/tests/it/lock.rs @@ -14913,6 +14913,100 @@ fn lock_invalid_project_table() -> Result<()> { Ok(()) } +#[test] +fn lock_unsupported_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" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = ["iniconfig==2.0.0"] + + [build-system] + requires = ["setuptools>=42"] + build-backend = "setuptools.build_meta" + "#, + )?; + + // Validate schema, invalid version. + context.temp_dir.child("uv.lock").write_str( + r#" + version = 2 + requires-python = ">=3.12" + + [options] + exclude-newer = "2024-03-25T00:00:00Z" + + [[package]] + name = "iniconfig" + version = "2.0.0" + source = { registry = "https://pypi.org/simple" } + sdist = { url = "https://files.pythonhosted.org/packages/d7/4b/cbd8e699e64a6f16ca3a8220661b5f83792b3017d0f79807cb8708d33913/iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3", size = 4646 } + wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/a6/62565a6e1cf69e10f5727360368e451d4b7f58beeac6173dc9db836a5b46/iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374", size = 5892 }, + ] + + [[package]] + name = "project" + version = "0.1.0" + source = { editable = "." } + dependencies = [ + { name = "iniconfig" }, + ] + + [package.metadata] + requires-dist = [{ name = "iniconfig", specifier = "==2.0.0" }] + "#, + )?; + + uv_snapshot!(context.filters(), context.lock().arg("--frozen"), @r###" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + error: The lockfile at `uv.lock` uses an unsupported schema version (v2, but only v1 is supported). Downgrade to a compatible uv version, or remove the `uv.lock` prior to running `uv lock` or `uv sync`. + "###); + + // Invalid schema (`iniconfig` is referenced, but missing), invalid version. + context.temp_dir.child("uv.lock").write_str( + r#" + version = 2 + requires-python = ">=3.12" + + [options] + exclude-newer = "2024-03-25T00:00:00Z" + + [[package]] + name = "project" + version = "0.1.0" + source = { editable = "." } + dependencies = [ + { name = "iniconfig" }, + ] + + [package.metadata] + requires-dist = [{ name = "iniconfig", specifier = "==2.0.0" }] + "#, + )?; + + uv_snapshot!(context.filters(), context.lock().arg("--frozen"), @r###" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + error: Failed to parse `uv.lock`, which uses an unsupported schema version (v2, but only v1 is supported). Downgrade to a compatible uv version, or remove the `uv.lock` prior to running `uv lock` or `uv sync`. + Caused by: Dependency `iniconfig` has missing `version` field but has more than one matching package + "###); + + Ok(()) +} + /// See: #[test] fn lock_change_requires_python() -> Result<()> { diff --git a/docs/concepts/resolution.md b/docs/concepts/resolution.md index fdf679b8cb3da..6fffda4fc44e9 100644 --- a/docs/concepts/resolution.md +++ b/docs/concepts/resolution.md @@ -397,3 +397,21 @@ reading and extracting archives in the following formats: For more details about the internals of the resolver, see the [resolver reference](../reference/resolver-internals.md) documentation. + +## Lockfile versioning + +The `uv.lock` file uses a versioned schema. The schema version is included in the `version` field of +the lockfile. + +Any given version of uv can read and write lockfiles with the same schema version, but will reject +lockfiles with a greater schema version. For example, if your uv version supports schema v1, +`uv lock` will error if it encounters an existing lockfile with schema v2. + +uv versions that support schema v2 _may_ be able to read lockfiles with schema v1 if the schema +update was backwards-compatible. However, this is not guaranteed, and uv may exit with an error if +it encounters a lockfile with an outdated schema version. + +The schema version is considered part of the public API, and so is only bumped in minor releases, as +a breaking change (see [Versioning](../reference/versioning.md)). As such, all uv patch versions +within a given minor uv release are guaranteed to have full lockfile compatibility. In other words, +lockfiles may only be rejected across minor releases. diff --git a/docs/reference/versioning.md b/docs/reference/versioning.md index 3006059602105..62bab9abe6fda 100644 --- a/docs/reference/versioning.md +++ b/docs/reference/versioning.md @@ -7,3 +7,14 @@ uv does not yet have a stable API; once uv's API is stable (v1.0.0), the version adhere to [Semantic Versioning](https://semver.org/). uv's changelog can be [viewed on GitHub](https://github.com/astral-sh/uv/blob/main/CHANGELOG.md). + +## Cache versioning + +Cache versions are considered internal to uv, and so may be changed in a minor or patch release. See +[Cache versioning](../concepts/cache.md#cache-versioning) for more. + +## Lockfile versioning + +The `uv.lock` schema version is considered part of the public API, and so will only be incremented +in a minor release as a breaking change. See +[Lockfile versioning](../concepts/resolution.md#lockfile-versioning) for more.