diff --git a/crates/uv-python/src/version_files.rs b/crates/uv-python/src/version_files.rs index 50f0cb3eed80..b697f0cce886 100644 --- a/crates/uv-python/src/version_files.rs +++ b/crates/uv-python/src/version_files.rs @@ -129,6 +129,12 @@ impl PythonVersionFile { &self.path } + /// Return the file name of the version file (guaranteed to be one of `.python-version` or + /// `.python-versions`). + pub fn file_name(&self) -> &str { + self.path.file_name().unwrap().to_str().unwrap() + } + /// Set the versions for the file. #[must_use] pub fn with_versions(self, versions: Vec) -> Self { diff --git a/crates/uv/src/commands/project/mod.rs b/crates/uv/src/commands/project/mod.rs index 97e3e9f31b6c..828928fecf67 100644 --- a/crates/uv/src/commands/project/mod.rs +++ b/crates/uv/src/commands/project/mod.rs @@ -63,11 +63,36 @@ pub(crate) enum ProjectError { #[error("The current Python platform is not compatible with the lockfile's supported environments: {0}")] LockedPlatformIncompatibility(String), - #[error("The requested Python interpreter ({0}) is incompatible with the project Python requirement: `{1}`")] + #[error("The requested interpreter resolved to Python {0}, which is incompatible with the project's Python requirement: `{1}`")] RequestedPythonIncompatibility(Version, RequiresPython), - #[error("The requested Python interpreter ({0}) is incompatible with the project Python requirement: `{1}`. However, a workspace member (`{member}`) supports Python {3}. To install the workspace member on its own, navigate to `{path}`, then run `{venv}` followed by `{install}`.", member = _2.cyan(), venv = format!("uv venv --python {_0}").green(), install = "uv pip install -e .".green(), path = _4.user_display().cyan() )] - RequestedMemberPythonIncompatibility( + #[error("The Python request from `{0}` resolved to Python {1}, which incompatible with the project's Python requirement: `{2}`")] + DotPythonVersionPythonIncompatibility(String, Version, RequiresPython), + + #[error("The resolved Python interpreter (Python {0}) is incompatible with the project's Python requirement: `{1}`")] + RequiresPythonIncompatibility(Version, RequiresPython), + + #[error("The requested interpreter resolved to Python {0}, which is incompatible with the project's Python requirement: `{1}`. However, a workspace member (`{member}`) supports Python {3}. To install the workspace member on its own, navigate to `{path}`, then run `{venv}` followed by `{install}`.", member = _2.cyan(), venv = format!("uv venv --python {_0}").green(), install = "uv pip install -e .".green(), path = _4.user_display().cyan() )] + RequestedMemberIncompatibility( + Version, + RequiresPython, + PackageName, + VersionSpecifiers, + PathBuf, + ), + + #[error("The Python request from `{0}` resolved to Python {1}, which incompatible with the project's Python requirement: `{2}`. However, a workspace member (`{member}`) supports Python {4}. To install the workspace member on its own, navigate to `{path}`, then run `{venv}` followed by `{install}`.", member = _3.cyan(), venv = format!("uv venv --python {_1}").green(), install = "uv pip install -e .".green(), path = _5.user_display().cyan() )] + DotPythonVersionMemberIncompatibility( + String, + Version, + RequiresPython, + PackageName, + VersionSpecifiers, + PathBuf, + ), + + #[error("The resolved Python interpreter (Python {0}) is incompatible with the project's Python requirement: `{1}`. However, a workspace member (`{member}`) supports Python {3}. To install the workspace member on its own, navigate to `{path}`, then run `{venv}` followed by `{install}`.", member = _2.cyan(), venv = format!("uv venv --python {_0}").green(), install = "uv pip install -e .".green(), path = _4.user_display().cyan() )] + RequiresPythonMemberIncompatibility( Version, RequiresPython, PackageName, @@ -161,38 +186,75 @@ pub(crate) fn validate_requires_python( interpreter: &Interpreter, workspace: &Workspace, requires_python: &RequiresPython, + source: &WorkspacePythonSource, ) -> Result<(), ProjectError> { - if !requires_python.contains(interpreter.python_version()) { - // If the Python version is compatible with one of the workspace _members_, raise - // a dedicated error. For example, if the workspace root requires Python >=3.12, but - // a library in the workspace is compatible with Python >=3.8, the user may attempt - // to sync on Python 3.8. This will fail, but we should provide a more helpful error - // message. - for (name, member) in workspace.packages() { - let Some(project) = member.pyproject_toml().project.as_ref() else { - continue; - }; - let Some(specifiers) = project.requires_python.as_ref() else { - continue; + if requires_python.contains(interpreter.python_version()) { + return Ok(()); + } + + // If the Python version is compatible with one of the workspace _members_, raise + // a dedicated error. For example, if the workspace root requires Python >=3.12, but + // a library in the workspace is compatible with Python >=3.8, the user may attempt + // to sync on Python 3.8. This will fail, but we should provide a more helpful error + // message. + for (name, member) in workspace.packages() { + let Some(project) = member.pyproject_toml().project.as_ref() else { + continue; + }; + let Some(specifiers) = project.requires_python.as_ref() else { + continue; + }; + if specifiers.contains(interpreter.python_version()) { + return match source { + WorkspacePythonSource::UserRequest => { + Err(ProjectError::RequestedMemberIncompatibility( + interpreter.python_version().clone(), + requires_python.clone(), + name.clone(), + specifiers.clone(), + member.root().clone(), + )) + } + WorkspacePythonSource::DotPythonVersion(file) => { + Err(ProjectError::DotPythonVersionMemberIncompatibility( + file.to_string(), + interpreter.python_version().clone(), + requires_python.clone(), + name.clone(), + specifiers.clone(), + member.root().clone(), + )) + } + WorkspacePythonSource::RequiresPython => { + Err(ProjectError::RequiresPythonMemberIncompatibility( + interpreter.python_version().clone(), + requires_python.clone(), + name.clone(), + specifiers.clone(), + member.root().clone(), + )) + } }; - if specifiers.contains(interpreter.python_version()) { - return Err(ProjectError::RequestedMemberPythonIncompatibility( - interpreter.python_version().clone(), - requires_python.clone(), - name.clone(), - specifiers.clone(), - member.root().clone(), - )); - } } + } - return Err(ProjectError::RequestedPythonIncompatibility( + match source { + WorkspacePythonSource::UserRequest => Err(ProjectError::RequestedPythonIncompatibility( + interpreter.python_version().clone(), + requires_python.clone(), + )), + WorkspacePythonSource::DotPythonVersion(file) => { + Err(ProjectError::DotPythonVersionPythonIncompatibility( + file.to_string(), + interpreter.python_version().clone(), + requires_python.clone(), + )) + } + WorkspacePythonSource::RequiresPython => Err(ProjectError::RequiresPythonIncompatibility( interpreter.python_version().clone(), requires_python.clone(), - )); + )), } - - Ok(()) } /// Find the virtual environment for the current project. @@ -210,9 +272,21 @@ pub(crate) enum FoundInterpreter { Environment(PythonEnvironment), } +#[derive(Debug, Clone)] +pub(crate) enum WorkspacePythonSource { + /// The request was provided by the user. + UserRequest, + /// The request was inferred from a `.python-version` or `.python-versions` file. + DotPythonVersion(String), + /// The request was inferred from a `pyproject.toml` file. + RequiresPython, +} + /// The resolved Python request and requirement for a [`Workspace`]. #[derive(Debug, Clone)] pub(crate) struct WorkspacePython { + /// The source of the Python request. + source: WorkspacePythonSource, /// The resolved Python request, computed by considering (1) any explicit request from the user /// via `--python`, (2) any implicit request from the user via `.python-version`, and (3) any /// `Requires-Python` specifier in the `pyproject.toml`. @@ -230,25 +304,32 @@ impl WorkspacePython { ) -> Result { let requires_python = find_requires_python(workspace)?; - // (1) Explicit request from user - let python_request = if let Some(request) = python_request { - Some(request) - // (2) Request from `.python-version` - } else if let Some(request) = - PythonVersionFile::discover(workspace.install_path(), false, false) - .await? - .and_then(PythonVersionFile::into_version) + let (source, python_request) = if let Some(request) = python_request { + // (1) Explicit request from user + let source = WorkspacePythonSource::UserRequest; + let request = Some(request); + (source, request) + } else if let Some(file) = + PythonVersionFile::discover(workspace.install_path(), false, false).await? { - Some(request) - // (3) `Requires-Python` in `pyproject.toml` + // (2) Request from `.python-version` + let source = WorkspacePythonSource::DotPythonVersion(file.file_name().to_string()); + let request = file.into_version(); + (source, request) } else { - requires_python + // (3) `Requires-Python` in `pyproject.toml` + let request = requires_python .as_ref() .map(RequiresPython::specifiers) - .map(|specifiers| PythonRequest::Version(VersionRequest::Range(specifiers.clone()))) + .map(|specifiers| { + PythonRequest::Version(VersionRequest::Range(specifiers.clone())) + }); + let source = WorkspacePythonSource::RequiresPython; + (source, request) }; Ok(Self { + source, python_request, requires_python, }) @@ -269,6 +350,7 @@ impl FoundInterpreter { ) -> Result { // Resolve the Python request and requirement for the workspace. let WorkspacePython { + source, python_request, requires_python, } = WorkspacePython::from_request(python_request, workspace).await?; @@ -346,7 +428,7 @@ impl FoundInterpreter { } if let Some(requires_python) = requires_python.as_ref() { - validate_requires_python(&interpreter, workspace, requires_python)?; + validate_requires_python(&interpreter, workspace, requires_python, &source)?; } Ok(Self::Interpreter(interpreter)) diff --git a/crates/uv/src/commands/project/run.rs b/crates/uv/src/commands/project/run.rs index 16a6a1c4b033..fa71e7a3807e 100644 --- a/crates/uv/src/commands/project/run.rs +++ b/crates/uv/src/commands/project/run.rs @@ -358,6 +358,7 @@ pub(crate) async fn run( // Resolve the Python request and requirement for the workspace. let WorkspacePython { + source, python_request, requires_python, } = WorkspacePython::from_request( @@ -379,7 +380,12 @@ pub(crate) async fn run( .into_interpreter(); if let Some(requires_python) = requires_python.as_ref() { - validate_requires_python(&interpreter, project.workspace(), requires_python)?; + validate_requires_python( + &interpreter, + project.workspace(), + requires_python, + &source, + )?; } // Create a virtual environment diff --git a/crates/uv/tests/lock.rs b/crates/uv/tests/lock.rs index aad5d8bd8980..88c6254e07cd 100644 --- a/crates/uv/tests/lock.rs +++ b/crates/uv/tests/lock.rs @@ -12581,3 +12581,50 @@ fn lock_strip_fragment() -> Result<()> { Ok(()) } + +#[test] +fn lock_request_requires_python() -> 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.8, <=3.10" + dependencies = ["iniconfig"] + + [build-system] + requires = ["setuptools>=42"] + build-backend = "setuptools.build_meta" + "#, + )?; + + // Request a version that conflicts with `--requires-python`. + uv_snapshot!(context.filters(), context.lock().arg("--python").arg("3.12"), @r###" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + Using Python 3.12.[X] interpreter at: [PYTHON-3.12] + error: The requested interpreter resolved to Python 3.12.[X], which is incompatible with the project's Python requirement: `>=3.8, <=3.10` + "###); + + // Add a `.python-version` file that conflicts. + let python_version = context.temp_dir.child(".python-version"); + python_version.write_str("3.12")?; + + uv_snapshot!(context.filters(), context.lock(), @r###" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + Using Python 3.12.[X] interpreter at: [PYTHON-3.12] + error: The Python request from `.python-version` resolved to Python 3.12.[X], which incompatible with the project's Python requirement: `>=3.8, <=3.10` + "###); + + Ok(()) +} diff --git a/crates/uv/tests/run.rs b/crates/uv/tests/run.rs index c12fd5cd6442..ba39c1ca9964 100644 --- a/crates/uv/tests/run.rs +++ b/crates/uv/tests/run.rs @@ -133,7 +133,7 @@ fn run_with_python_version() -> Result<()> { ----- stderr ----- Using Python 3.8.[X] interpreter at: [PYTHON-3.8] - error: The requested Python interpreter (3.8.[X]) is incompatible with the project Python requirement: `>=3.11, <4` + error: The requested interpreter resolved to Python 3.8.[X], which is incompatible with the project's Python requirement: `>=3.11, <4` "###); Ok(()) @@ -1657,7 +1657,7 @@ fn run_isolated_incompatible_python() -> Result<()> { ----- stderr ----- Using Python 3.8.[X] interpreter at: [PYTHON-3.8] - error: The requested Python interpreter (3.8.[X]) is incompatible with the project Python requirement: `>=3.12` + error: The Python request from `.python-version` resolved to Python 3.8.[X], which incompatible with the project's Python requirement: `>=3.12` "###); // ...even if `--isolated` is provided. @@ -1667,7 +1667,7 @@ fn run_isolated_incompatible_python() -> Result<()> { ----- stdout ----- ----- stderr ----- - error: The requested Python interpreter (3.8.[X]) is incompatible with the project Python requirement: `>=3.12` + error: The Python request from `.python-version` resolved to Python 3.8.[X], which incompatible with the project's Python requirement: `>=3.12` "###); Ok(()) diff --git a/crates/uv/tests/sync.rs b/crates/uv/tests/sync.rs index a67dc1aa01cd..80093b99f151 100644 --- a/crates/uv/tests/sync.rs +++ b/crates/uv/tests/sync.rs @@ -378,7 +378,7 @@ fn mixed_requires_python() -> Result<()> { ----- stderr ----- Using Python 3.8.[X] interpreter at: [PYTHON-3.8] - error: The requested Python interpreter (3.8.[X]) is incompatible with the project Python requirement: `>=3.12`. However, a workspace member (`bird-feeder`) supports Python >=3.8. To install the workspace member on its own, navigate to `packages/bird-feeder`, then run `uv venv --python 3.8.[X]` followed by `uv pip install -e .`. + error: The requested interpreter resolved to Python 3.8.[X], which is incompatible with the project's Python requirement: `>=3.12`. However, a workspace member (`bird-feeder`) supports Python >=3.8. To install the workspace member on its own, navigate to `packages/bird-feeder`, then run `uv venv --python 3.8.[X]` followed by `uv pip install -e .`. "###); Ok(())