From d330570c2a6b0ca933335e0bca9f927c18f71776 Mon Sep 17 00:00:00 2001 From: Charlie Marsh Date: Wed, 5 Jun 2024 15:39:26 -0400 Subject: [PATCH 1/2] Track supported Python range in lockfile --- crates/uv-resolver/src/lock.rs | 22 ++++++++++++++++--- crates/uv-resolver/src/python_requirement.rs | 13 +++++++++-- crates/uv-resolver/src/resolution/graph.rs | 21 +++++++++++++++--- crates/uv-resolver/src/resolver/mod.rs | 8 ++++++- ...r__lock__tests__hash_optional_missing.snap | 1 + crates/uv/tests/lock.rs | 2 ++ 6 files changed, 58 insertions(+), 9 deletions(-) diff --git a/crates/uv-resolver/src/lock.rs b/crates/uv-resolver/src/lock.rs index 078db6050f89..f2c3e5dfe830 100644 --- a/crates/uv-resolver/src/lock.rs +++ b/crates/uv-resolver/src/lock.rs @@ -20,7 +20,7 @@ use distribution_types::{ GitSourceDist, IndexUrl, PathBuiltDist, PathSourceDist, RegistryBuiltDist, RegistryBuiltWheel, RegistrySourceDist, RemoteSource, Resolution, ResolvedDist, ToUrlError, }; -use pep440_rs::Version; +use pep440_rs::{Version, VersionSpecifiers}; use pep508_rs::{MarkerEnvironment, MarkerTree, VerbatimUrl}; use platform_tags::{TagCompatibility, TagPriority, Tags}; use pypi_types::{HashDigest, ParsedArchiveUrl, ParsedGitUrl}; @@ -36,6 +36,8 @@ use crate::{lock, ResolutionGraph}; pub struct Lock { version: u32, distributions: Vec, + /// The range of supported Python versions. + requires_python: Option, /// A map from distribution ID to index in `distributions`. /// /// This can be used to quickly lookup the full distribution for any ID @@ -87,15 +89,21 @@ impl Lock { } } - let lock = Self::new(locked_dists.into_values().collect())?; + let distributions = locked_dists.into_values().collect(); + let requires_python = graph.requires_python.clone(); + let lock = Self::new(distributions, requires_python)?; Ok(lock) } /// Initialize a [`Lock`] from a list of [`Distribution`] entries. - fn new(distributions: Vec) -> Result { + fn new( + distributions: Vec, + requires_python: Option, + ) -> Result { let wire = LockWire { version: 1, distributions, + requires_python, }; Self::try_from(wire) } @@ -196,6 +204,8 @@ struct LockWire { version: u32, #[serde(rename = "distribution")] distributions: Vec, + #[serde(rename = "requires-python")] + requires_python: Option, } impl From for LockWire { @@ -203,6 +213,7 @@ impl From for LockWire { LockWire { version: lock.version, distributions: lock.distributions, + requires_python: lock.requires_python, } } } @@ -215,6 +226,10 @@ impl Lock { let mut doc = toml_edit::DocumentMut::new(); doc.insert("version", value(i64::from(self.version))); + if let Some(ref requires_python) = self.requires_python { + doc.insert("requires-python", value(requires_python.to_string())); + } + let mut distributions = ArrayOfTables::new(); for dist in &self.distributions { let mut table = Table::new(); @@ -344,6 +359,7 @@ impl TryFrom for Lock { Ok(Lock { version: wire.version, distributions: wire.distributions, + requires_python: wire.requires_python, by_id, }) } diff --git a/crates/uv-resolver/src/python_requirement.rs b/crates/uv-resolver/src/python_requirement.rs index 0b8c2598ef44..f1c4278aba21 100644 --- a/crates/uv-resolver/src/python_requirement.rs +++ b/crates/uv-resolver/src/python_requirement.rs @@ -60,13 +60,14 @@ impl PythonRequirement { #[derive(Debug, Clone, Eq, PartialEq)] pub enum RequiresPython { - /// The `RequiresPython` specifier is a single version specifier, as provided via + /// The [`RequiresPython`] specifier is a single version specifier, as provided via /// `--python-version` on the command line. /// /// The use of a separate enum variant allows us to use a verbatim representation when reporting /// back to the user. Specifier(StringVersion), - /// The `RequiresPython` specifier is a set of version specifiers. + /// The [`RequiresPython`] specifier is a set of version specifiers, as extracted from the + /// `Requires-Python` field in a `pyproject.toml` or `METADATA` file. Specifiers(VersionSpecifiers), } @@ -93,6 +94,14 @@ impl RequiresPython { } } } + + /// Returns the [`VersionSpecifiers`] for the [`RequiresPython`] specifier. + pub fn as_specifiers(&self) -> Option<&VersionSpecifiers> { + match self { + RequiresPython::Specifier(_) => None, + RequiresPython::Specifiers(specifiers) => Some(specifiers), + } + } } impl std::fmt::Display for RequiresPython { diff --git a/crates/uv-resolver/src/resolution/graph.rs b/crates/uv-resolver/src/resolution/graph.rs index b63e4626bc95..6a6a91389c81 100644 --- a/crates/uv-resolver/src/resolution/graph.rs +++ b/crates/uv-resolver/src/resolution/graph.rs @@ -9,7 +9,7 @@ use rustc_hash::{FxHashMap, FxHashSet}; use distribution_types::{ Dist, DistributionMetadata, Name, ResolutionDiagnostic, VersionId, VersionOrUrlRef, }; -use pep440_rs::{Version, VersionSpecifier}; +use pep440_rs::{Version, VersionSpecifier, VersionSpecifiers}; use pep508_rs::{MarkerEnvironment, MarkerTree}; use pypi_types::{ParsedUrlError, Yanked}; use uv_git::GitResolver; @@ -17,10 +17,13 @@ use uv_normalize::{ExtraName, PackageName}; use crate::preferences::Preferences; use crate::pubgrub::{PubGrubDistribution, PubGrubPackageInner}; +use crate::python_requirement::RequiresPython; use crate::redirect::url_to_precise; use crate::resolution::AnnotatedDist; use crate::resolver::Resolution; -use crate::{InMemoryIndex, Manifest, MetadataResponse, ResolveError, VersionsResponse}; +use crate::{ + InMemoryIndex, Manifest, MetadataResponse, PythonRequirement, ResolveError, VersionsResponse, +}; /// A complete resolution graph in which every node represents a pinned package and every edge /// represents a dependency between two pinned packages. @@ -28,6 +31,8 @@ use crate::{InMemoryIndex, Manifest, MetadataResponse, ResolveError, VersionsRes pub struct ResolutionGraph { /// The underlying graph. pub(crate) petgraph: Graph, + /// The range of supported Python versions. + pub(crate) requires_python: Option, /// Any diagnostics that were encountered while building the graph. pub(crate) diagnostics: Vec, } @@ -39,9 +44,10 @@ impl ResolutionGraph { index: &InMemoryIndex, preferences: &Preferences, git: &GitResolver, + python: &PythonRequirement, resolution: Resolution, ) -> anyhow::Result { - // Collect all marker expressions from relevant pubgrub packages. + // Collect all marker expressions from relevant PubGrub packages. let mut markers: FxHashMap<(&PackageName, &Version, &Option), MarkerTree> = FxHashMap::default(); for (package, versions) in &resolution.packages { @@ -267,8 +273,17 @@ impl ResolutionGraph { } } + // Extract the `Requires-Python` range, if provided. + // TODO(charlie): Infer the supported Python range from the `Requires-Python` of the + // included packages. + let requires_python = python + .target() + .and_then(RequiresPython::as_specifiers) + .cloned(); + Ok(Self { petgraph, + requires_python, diagnostics, }) } diff --git a/crates/uv-resolver/src/resolver/mod.rs b/crates/uv-resolver/src/resolver/mod.rs index 145cd580230a..b94995502649 100644 --- a/crates/uv-resolver/src/resolver/mod.rs +++ b/crates/uv-resolver/src/resolver/mod.rs @@ -557,7 +557,13 @@ impl ResolverState Result<()> { assert_snapshot!( lock, @r###" version = 1 + requires-python = ">=3.7" [[distribution]] name = "dataclasses" @@ -1163,6 +1164,7 @@ fn lock_requires_python() -> Result<()> { assert_snapshot!( lock, @r###" version = 1 + requires-python = ">=3.7.9, <4" [[distribution]] name = "attrs" From 74b82a9ea8429e4da932b6cfc21da8dd5fe7e242 Mon Sep 17 00:00:00 2001 From: Charlie Marsh Date: Wed, 5 Jun 2024 16:00:42 -0400 Subject: [PATCH 2/2] Enforce Requires-Python when syncing --- crates/uv-resolver/src/lock.rs | 5 + crates/uv/src/commands/project/mod.rs | 4 + crates/uv/src/commands/project/sync.rs | 10 ++ crates/uv/tests/lock.rs | 185 ++++++++++++++++++++++++- 4 files changed, 199 insertions(+), 5 deletions(-) diff --git a/crates/uv-resolver/src/lock.rs b/crates/uv-resolver/src/lock.rs index f2c3e5dfe830..3275ad5734fa 100644 --- a/crates/uv-resolver/src/lock.rs +++ b/crates/uv-resolver/src/lock.rs @@ -113,6 +113,11 @@ impl Lock { &self.distributions } + /// Returns the supported Python version range for the lockfile, if present. + pub fn requires_python(&self) -> Option<&VersionSpecifiers> { + self.requires_python.as_ref() + } + /// Convert the [`Lock`] to a [`Resolution`] using the given marker environment, tags, and root. pub fn to_resolution( &self, diff --git a/crates/uv/src/commands/project/mod.rs b/crates/uv/src/commands/project/mod.rs index 135600f774cc..c3b5772c4c99 100644 --- a/crates/uv/src/commands/project/mod.rs +++ b/crates/uv/src/commands/project/mod.rs @@ -7,6 +7,7 @@ use tracing::debug; use distribution_types::{IndexLocations, Resolution}; use install_wheel_rs::linker::LinkMode; +use pep440_rs::{Version, VersionSpecifiers}; use uv_cache::Cache; use uv_client::{BaseClientBuilder, Connectivity, RegistryClientBuilder}; use uv_configuration::{ @@ -32,6 +33,9 @@ pub(crate) mod sync; #[derive(thiserror::Error, Debug)] pub(crate) enum ProjectError { + #[error("The current Python version ({0}) is not compatible with the locked Python requirement ({1})")] + RequiresPython(Version, VersionSpecifiers), + #[error(transparent)] Interpreter(#[from] uv_interpreter::Error), diff --git a/crates/uv/src/commands/project/sync.rs b/crates/uv/src/commands/project/sync.rs index dede9702746e..5be104a4e259 100644 --- a/crates/uv/src/commands/project/sync.rs +++ b/crates/uv/src/commands/project/sync.rs @@ -76,6 +76,16 @@ pub(super) async fn do_sync( cache: &Cache, printer: Printer, ) -> Result<(), ProjectError> { + // Validate that the Python version is supported by the lockfile. + if let Some(requires_python) = lock.requires_python() { + if !requires_python.contains(venv.interpreter().python_version()) { + return Err(ProjectError::RequiresPython( + venv.interpreter().python_version().clone(), + requires_python.clone(), + )); + } + } + let markers = venv.interpreter().markers(); let tags = venv.interpreter().tags()?; diff --git a/crates/uv/tests/lock.rs b/crates/uv/tests/lock.rs index a377d5016cb9..9d20d142eb27 100644 --- a/crates/uv/tests/lock.rs +++ b/crates/uv/tests/lock.rs @@ -971,7 +971,9 @@ fn lock_git_sha() -> Result<()> { fn lock_requires_python() -> Result<()> { let context = TestContext::new("3.12"); - // Require >=3.7, which is incompatible with newer versions of `pygls`. + let lockfile = context.temp_dir.join("uv.lock"); + + // Require >=3.7, which is incompatible with newer versions of `pygls` (>=1.1.0). let pyproject_toml = context.temp_dir.child("pyproject.toml"); pyproject_toml.write_str( r#" @@ -1009,7 +1011,6 @@ fn lock_requires_python() -> Result<()> { "###); // Require >=3.7, and allow locking to a version of `pygls` that is compatible. - let pyproject_toml = context.temp_dir.child("pyproject.toml"); pyproject_toml.write_str( r#" [project] @@ -1030,7 +1031,7 @@ fn lock_requires_python() -> Result<()> { Resolved 6 packages in [TIME] "###); - let lock = fs_err::read_to_string(context.temp_dir.join("uv.lock"))?; + let lock = fs_err::read_to_string(&lockfile)?; insta::with_settings!({ filters => context.filters(), @@ -1133,7 +1134,7 @@ fn lock_requires_python() -> Result<()> { }); // Remove the lockfile. - fs_err::remove_file(context.temp_dir.join("uv.lock"))?; + fs_err::remove_file(&lockfile)?; // Bump the Python requirement, which should allow a newer version of `pygls`. pyproject_toml.write_str( @@ -1156,7 +1157,7 @@ fn lock_requires_python() -> Result<()> { Resolved 9 packages in [TIME] "###); - let lock = fs_err::read_to_string(context.temp_dir.join("uv.lock"))?; + let lock = fs_err::read_to_string(&lockfile)?; insta::with_settings!({ filters => context.filters(), @@ -1284,5 +1285,179 @@ fn lock_requires_python() -> Result<()> { ); }); + // Remove the lockfile. + fs_err::remove_file(&lockfile)?; + + // Bump the Python requirement even further. + pyproject_toml.write_str( + r#" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = ["pygls"] + "#, + )?; + + uv_snapshot!(context.filters(), context.lock(), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + warning: `uv lock` is experimental and may change without warning. + Resolved 9 packages in [TIME] + "###); + + let lock = fs_err::read_to_string(&lockfile)?; + + insta::with_settings!({ + filters => context.filters(), + }, { + assert_snapshot!( + lock, @r###" + version = 1 + requires-python = ">=3.12" + + [[distribution]] + name = "attrs" + version = "23.2.0" + source = "registry+https://pypi.org/simple" + sdist = { url = "https://files.pythonhosted.org/packages/e3/fc/f800d51204003fa8ae392c4e8278f256206e7a919b708eef054f5f4b650d/attrs-23.2.0.tar.gz", hash = "sha256:935dc3b529c262f6cf76e50877d35a4bd3c1de194fd41f47a2b7ae8f19971f30", size = 780820 } + wheels = [{ url = "https://files.pythonhosted.org/packages/e0/44/827b2a91a5816512fcaf3cc4ebc465ccd5d598c45cefa6703fcf4a79018f/attrs-23.2.0-py3-none-any.whl", hash = "sha256:99b87a485a5820b23b879f04c2305b44b951b502fd64be915879d77a7e8fc6f1", size = 60752 }] + + [[distribution.dependencies]] + name = "importlib-metadata" + version = "7.1.0" + source = "registry+https://pypi.org/simple" + + [[distribution]] + name = "cattrs" + version = "23.2.3" + source = "registry+https://pypi.org/simple" + sdist = { url = "https://files.pythonhosted.org/packages/1e/57/c6ccd22658c4bcb3beb3f1c262e1f170cf136e913b122763d0ddd328d284/cattrs-23.2.3.tar.gz", hash = "sha256:a934090d95abaa9e911dac357e3a8699e0b4b14f8529bcc7d2b1ad9d51672b9f", size = 610215 } + wheels = [{ url = "https://files.pythonhosted.org/packages/b3/0d/cd4a4071c7f38385dc5ba91286723b4d1090b87815db48216212c6c6c30e/cattrs-23.2.3-py3-none-any.whl", hash = "sha256:0341994d94971052e9ee70662542699a3162ea1e0c62f7ce1b4a57f563685108", size = 57474 }] + + [[distribution.dependencies]] + name = "attrs" + version = "23.2.0" + source = "registry+https://pypi.org/simple" + + [[distribution.dependencies]] + name = "exceptiongroup" + version = "1.2.0" + source = "registry+https://pypi.org/simple" + + [[distribution.dependencies]] + name = "typing-extensions" + version = "4.10.0" + source = "registry+https://pypi.org/simple" + + [[distribution]] + name = "exceptiongroup" + version = "1.2.0" + source = "registry+https://pypi.org/simple" + marker = "python_version < '3.11'" + sdist = { url = "https://files.pythonhosted.org/packages/8e/1c/beef724eaf5b01bb44b6338c8c3494eff7cab376fab4904cfbbc3585dc79/exceptiongroup-1.2.0.tar.gz", hash = "sha256:91f5c769735f051a4290d52edd0858999b57e5876e9f85937691bd4c9fa3ed68", size = 26264 } + wheels = [{ url = "https://files.pythonhosted.org/packages/b8/9a/5028fd52db10e600f1c4674441b968cf2ea4959085bfb5b99fb1250e5f68/exceptiongroup-1.2.0-py3-none-any.whl", hash = "sha256:4bfd3996ac73b41e9b9628b04e079f193850720ea5945fc96a08633c66912f14", size = 16210 }] + + [[distribution]] + name = "importlib-metadata" + version = "7.1.0" + source = "registry+https://pypi.org/simple" + marker = "python_version < '3.8'" + sdist = { url = "https://files.pythonhosted.org/packages/a0/fc/c4e6078d21fc4fa56300a241b87eae76766aa380a23fc450fc85bb7bf547/importlib_metadata-7.1.0.tar.gz", hash = "sha256:b78938b926ee8d5f020fc4772d487045805a55ddbad2ecf21c6d60938dc7fcd2", size = 52120 } + wheels = [{ url = "https://files.pythonhosted.org/packages/2d/0a/679461c511447ffaf176567d5c496d1de27cbe34a87df6677d7171b2fbd4/importlib_metadata-7.1.0-py3-none-any.whl", hash = "sha256:30962b96c0c223483ed6cc7280e7f0199feb01a0e40cfae4d4450fc6fab1f570", size = 24409 }] + + [[distribution.dependencies]] + name = "typing-extensions" + version = "4.10.0" + source = "registry+https://pypi.org/simple" + + [[distribution.dependencies]] + name = "zipp" + version = "3.18.1" + source = "registry+https://pypi.org/simple" + + [[distribution]] + name = "lsprotocol" + version = "2023.0.1" + source = "registry+https://pypi.org/simple" + sdist = { url = "https://files.pythonhosted.org/packages/9d/f6/6e80484ec078d0b50699ceb1833597b792a6c695f90c645fbaf54b947e6f/lsprotocol-2023.0.1.tar.gz", hash = "sha256:cc5c15130d2403c18b734304339e51242d3018a05c4f7d0f198ad6e0cd21861d", size = 69434 } + wheels = [{ url = "https://files.pythonhosted.org/packages/8d/37/2351e48cb3309673492d3a8c59d407b75fb6630e560eb27ecd4da03adc9a/lsprotocol-2023.0.1-py3-none-any.whl", hash = "sha256:c75223c9e4af2f24272b14c6375787438279369236cd568f596d4951052a60f2", size = 70826 }] + + [[distribution.dependencies]] + name = "attrs" + version = "23.2.0" + source = "registry+https://pypi.org/simple" + + [[distribution.dependencies]] + name = "cattrs" + version = "23.2.3" + source = "registry+https://pypi.org/simple" + + [[distribution]] + name = "project" + version = "0.1.0" + source = "editable+file://[TEMP_DIR]/" + sdist = { url = "file://[TEMP_DIR]/" } + + [[distribution.dependencies]] + name = "pygls" + version = "1.3.0" + source = "registry+https://pypi.org/simple" + + [[distribution]] + name = "pygls" + version = "1.3.0" + source = "registry+https://pypi.org/simple" + sdist = { url = "https://files.pythonhosted.org/packages/e9/8d/31b50ac0879464049d744a1ddf00dc6474433eb55d40fa0c8e8510591ad2/pygls-1.3.0.tar.gz", hash = "sha256:1b44ace89c9382437a717534f490eadc6fda7c0c6c16ac1eaaf5568e345e4fb8", size = 45539 } + wheels = [{ url = "https://files.pythonhosted.org/packages/4e/1e/643070d8f5c851958662e7e5df16d9c3a068a598a7ee7bb2eb8d95b4e5d7/pygls-1.3.0-py3-none-any.whl", hash = "sha256:d4a01414b6ed4e34e7e8fd29b77d3e88c29615df7d0bbff49bf019e15ec04b8f", size = 56031 }] + + [[distribution.dependencies]] + name = "cattrs" + version = "23.2.3" + source = "registry+https://pypi.org/simple" + + [[distribution.dependencies]] + name = "lsprotocol" + version = "2023.0.1" + source = "registry+https://pypi.org/simple" + + [[distribution]] + name = "typing-extensions" + version = "4.10.0" + source = "registry+https://pypi.org/simple" + marker = "python_version < '3.8' or python_version < '3.11'" + sdist = { url = "https://files.pythonhosted.org/packages/16/3a/0d26ce356c7465a19c9ea8814b960f8a36c3b0d07c323176620b7b483e44/typing_extensions-4.10.0.tar.gz", hash = "sha256:b0abd7c89e8fb96f98db18d86106ff1d90ab692004eb746cf6eda2682f91b3cb", size = 77558 } + wheels = [{ url = "https://files.pythonhosted.org/packages/f9/de/dc04a3ea60b22624b51c703a84bbe0184abcd1d0b9bc8074b5d6b7ab90bb/typing_extensions-4.10.0-py3-none-any.whl", hash = "sha256:69b1a937c3a517342112fb4c6df7e72fc39a38e7891a5730ed4985b5214b5475", size = 33926 }] + + [[distribution]] + name = "zipp" + version = "3.18.1" + source = "registry+https://pypi.org/simple" + sdist = { url = "https://files.pythonhosted.org/packages/3e/ef/65da662da6f9991e87f058bc90b91a935ae655a16ae5514660d6460d1298/zipp-3.18.1.tar.gz", hash = "sha256:2884ed22e7d8961de1c9a05142eb69a247f120291bc0206a00a7642f09b5b715", size = 21220 } + wheels = [{ url = "https://files.pythonhosted.org/packages/c2/0a/ba9d0ee9536d3ef73a3448e931776e658b36f128d344e175bc32b092a8bf/zipp-3.18.1-py3-none-any.whl", hash = "sha256:206f5a15f2af3dbaee80769fb7dc6f249695e940acca08dfb2a4769fe61e538b", size = 8247 }] + "### + ); + }); + + // Validate that attempting to install with an unsupported Python version raises an error. + let context = TestContext::new("3.8"); + + fs_err::copy(pyproject_toml, context.temp_dir.join("pyproject.toml"))?; + fs_err::copy(&lockfile, context.temp_dir.join("uv.lock"))?; + + // Install from the lockfile. + uv_snapshot!(context.filters(), context.sync(), @r###" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + warning: `uv sync` is experimental and may change without warning. + error: The current Python version (3.8.[X]) is not compatible with the locked Python requirement (>=3.12) + "###); + Ok(()) }