Skip to content

Commit

Permalink
Surface dedicated errors for .python-version conflict with `require…
Browse files Browse the repository at this point in the history
…s-python` (#7218)

## Summary

I got confused because I had a `.python-version` file that conflicted
with my `requires-python`.
  • Loading branch information
charliermarsh authored Sep 9, 2024
1 parent 5905f40 commit fe8880b
Show file tree
Hide file tree
Showing 6 changed files with 187 additions and 46 deletions.
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

0 comments on commit fe8880b

Please sign in to comment.