Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Surface dedicated errors for .python-version conflict with requires-python #7218

Merged
merged 1 commit into from
Sep 9, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions crates/uv-python/src/version_files.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<PythonRequest>) -> Self {
Expand Down
164 changes: 123 additions & 41 deletions crates/uv/src/commands/project/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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.
Expand All @@ -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`.
Expand All @@ -230,25 +304,32 @@ impl WorkspacePython {
) -> Result<Self, ProjectError> {
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,
})
Expand All @@ -269,6 +350,7 @@ impl FoundInterpreter {
) -> Result<Self, ProjectError> {
// Resolve the Python request and requirement for the workspace.
let WorkspacePython {
source,
python_request,
requires_python,
} = WorkspacePython::from_request(python_request, workspace).await?;
Expand Down Expand Up @@ -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))
Expand Down
8 changes: 7 additions & 1 deletion crates/uv/src/commands/project/run.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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
Expand Down
47 changes: 47 additions & 0 deletions crates/uv/tests/lock.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(())
}
6 changes: 3 additions & 3 deletions crates/uv/tests/run.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(())
Expand Down Expand Up @@ -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.
Expand All @@ -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(())
Expand Down
2 changes: 1 addition & 1 deletion crates/uv/tests/sync.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(())
Expand Down
Loading