Skip to content

Commit 752b111

Browse files
committed
Surface dedicated errors for .python-version conflict with requires-python
1 parent d9cd282 commit 752b111

File tree

6 files changed

+187
-46
lines changed

6 files changed

+187
-46
lines changed

crates/uv-python/src/version_files.rs

+6
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,12 @@ impl PythonVersionFile {
129129
&self.path
130130
}
131131

132+
/// Return the file name of the version file (guaranteed to be one of `.python-version` or
133+
/// `.python-versions`).
134+
pub fn file_name(&self) -> &str {
135+
self.path.file_name().unwrap().to_str().unwrap()
136+
}
137+
132138
/// Set the versions for the file.
133139
#[must_use]
134140
pub fn with_versions(self, versions: Vec<PythonRequest>) -> Self {

crates/uv/src/commands/project/mod.rs

+123-41
Original file line numberDiff line numberDiff line change
@@ -63,11 +63,36 @@ pub(crate) enum ProjectError {
6363
#[error("The current Python platform is not compatible with the lockfile's supported environments: {0}")]
6464
LockedPlatformIncompatibility(String),
6565

66-
#[error("The requested Python interpreter ({0}) is incompatible with the project Python requirement: `{1}`")]
66+
#[error("The requested Python interpreter resolved to Python {0}, which is incompatible with the project's Python requirement: `{1}`")]
6767
RequestedPythonIncompatibility(Version, RequiresPython),
6868

69-
#[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() )]
70-
RequestedMemberPythonIncompatibility(
69+
#[error("The Python request from `{0}` resolved to Python {1}, which incompatible with the project's Python requirement: `{2}`")]
70+
DotPythonVersionPythonIncompatibility(String, Version, RequiresPython),
71+
72+
#[error("The resolved Python interpreter (Python {0}) is incompatible with the project's Python requirement: `{1}`")]
73+
RequiresPythonIncompatibility(Version, RequiresPython),
74+
75+
#[error("The requested Python 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() )]
76+
RequestedMemberIncompatibility(
77+
Version,
78+
RequiresPython,
79+
PackageName,
80+
VersionSpecifiers,
81+
PathBuf,
82+
),
83+
84+
#[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() )]
85+
DotPythonVersionMemberIncompatibility(
86+
String,
87+
Version,
88+
RequiresPython,
89+
PackageName,
90+
VersionSpecifiers,
91+
PathBuf,
92+
),
93+
94+
#[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() )]
95+
RequiresPythonMemberIncompatibility(
7196
Version,
7297
RequiresPython,
7398
PackageName,
@@ -161,38 +186,75 @@ pub(crate) fn validate_requires_python(
161186
interpreter: &Interpreter,
162187
workspace: &Workspace,
163188
requires_python: &RequiresPython,
189+
source: &WorkspacePythonSource,
164190
) -> Result<(), ProjectError> {
165-
if !requires_python.contains(interpreter.python_version()) {
166-
// If the Python version is compatible with one of the workspace _members_, raise
167-
// a dedicated error. For example, if the workspace root requires Python >=3.12, but
168-
// a library in the workspace is compatible with Python >=3.8, the user may attempt
169-
// to sync on Python 3.8. This will fail, but we should provide a more helpful error
170-
// message.
171-
for (name, member) in workspace.packages() {
172-
let Some(project) = member.pyproject_toml().project.as_ref() else {
173-
continue;
174-
};
175-
let Some(specifiers) = project.requires_python.as_ref() else {
176-
continue;
191+
if requires_python.contains(interpreter.python_version()) {
192+
return Ok(());
193+
}
194+
195+
// If the Python version is compatible with one of the workspace _members_, raise
196+
// a dedicated error. For example, if the workspace root requires Python >=3.12, but
197+
// a library in the workspace is compatible with Python >=3.8, the user may attempt
198+
// to sync on Python 3.8. This will fail, but we should provide a more helpful error
199+
// message.
200+
for (name, member) in workspace.packages() {
201+
let Some(project) = member.pyproject_toml().project.as_ref() else {
202+
continue;
203+
};
204+
let Some(specifiers) = project.requires_python.as_ref() else {
205+
continue;
206+
};
207+
if specifiers.contains(interpreter.python_version()) {
208+
return match source {
209+
WorkspacePythonSource::UserRequest => {
210+
Err(ProjectError::RequestedMemberIncompatibility(
211+
interpreter.python_version().clone(),
212+
requires_python.clone(),
213+
name.clone(),
214+
specifiers.clone(),
215+
member.root().clone(),
216+
))
217+
}
218+
WorkspacePythonSource::DotPythonVersion(file) => {
219+
Err(ProjectError::DotPythonVersionMemberIncompatibility(
220+
file.to_string(),
221+
interpreter.python_version().clone(),
222+
requires_python.clone(),
223+
name.clone(),
224+
specifiers.clone(),
225+
member.root().clone(),
226+
))
227+
}
228+
WorkspacePythonSource::RequiresPython => {
229+
Err(ProjectError::RequiresPythonMemberIncompatibility(
230+
interpreter.python_version().clone(),
231+
requires_python.clone(),
232+
name.clone(),
233+
specifiers.clone(),
234+
member.root().clone(),
235+
))
236+
}
177237
};
178-
if specifiers.contains(interpreter.python_version()) {
179-
return Err(ProjectError::RequestedMemberPythonIncompatibility(
180-
interpreter.python_version().clone(),
181-
requires_python.clone(),
182-
name.clone(),
183-
specifiers.clone(),
184-
member.root().clone(),
185-
));
186-
}
187238
}
239+
}
188240

189-
return Err(ProjectError::RequestedPythonIncompatibility(
241+
match source {
242+
WorkspacePythonSource::UserRequest => Err(ProjectError::RequestedPythonIncompatibility(
243+
interpreter.python_version().clone(),
244+
requires_python.clone(),
245+
)),
246+
WorkspacePythonSource::DotPythonVersion(file) => {
247+
Err(ProjectError::DotPythonVersionPythonIncompatibility(
248+
file.to_string(),
249+
interpreter.python_version().clone(),
250+
requires_python.clone(),
251+
))
252+
}
253+
WorkspacePythonSource::RequiresPython => Err(ProjectError::RequiresPythonIncompatibility(
190254
interpreter.python_version().clone(),
191255
requires_python.clone(),
192-
));
256+
)),
193257
}
194-
195-
Ok(())
196258
}
197259

198260
/// Find the virtual environment for the current project.
@@ -210,9 +272,21 @@ pub(crate) enum FoundInterpreter {
210272
Environment(PythonEnvironment),
211273
}
212274

275+
#[derive(Debug, Clone)]
276+
pub(crate) enum WorkspacePythonSource {
277+
/// The request was provided by the user.
278+
UserRequest,
279+
/// The request was inferred from a `.python-version` or `.python-versions` file.
280+
DotPythonVersion(String),
281+
/// The request was inferred from a `pyproject.toml` file.
282+
RequiresPython,
283+
}
284+
213285
/// The resolved Python request and requirement for a [`Workspace`].
214286
#[derive(Debug, Clone)]
215287
pub(crate) struct WorkspacePython {
288+
/// The source of the Python request.
289+
source: WorkspacePythonSource,
216290
/// The resolved Python request, computed by considering (1) any explicit request from the user
217291
/// via `--python`, (2) any implicit request from the user via `.python-version`, and (3) any
218292
/// `Requires-Python` specifier in the `pyproject.toml`.
@@ -230,25 +304,32 @@ impl WorkspacePython {
230304
) -> Result<Self, ProjectError> {
231305
let requires_python = find_requires_python(workspace)?;
232306

233-
// (1) Explicit request from user
234-
let python_request = if let Some(request) = python_request {
235-
Some(request)
236-
// (2) Request from `.python-version`
237-
} else if let Some(request) =
238-
PythonVersionFile::discover(workspace.install_path(), false, false)
239-
.await?
240-
.and_then(PythonVersionFile::into_version)
307+
let (source, python_request) = if let Some(request) = python_request {
308+
// (1) Explicit request from user
309+
let source = WorkspacePythonSource::UserRequest;
310+
let request = Some(request);
311+
(source, request)
312+
} else if let Some(file) =
313+
PythonVersionFile::discover(workspace.install_path(), false, false).await?
241314
{
242-
Some(request)
243-
// (3) `Requires-Python` in `pyproject.toml`
315+
// (2) Request from `.python-version`
316+
let source = WorkspacePythonSource::DotPythonVersion(file.file_name().to_string());
317+
let request = file.into_version();
318+
(source, request)
244319
} else {
245-
requires_python
320+
// (3) `Requires-Python` in `pyproject.toml`
321+
let request = requires_python
246322
.as_ref()
247323
.map(RequiresPython::specifiers)
248-
.map(|specifiers| PythonRequest::Version(VersionRequest::Range(specifiers.clone())))
324+
.map(|specifiers| {
325+
PythonRequest::Version(VersionRequest::Range(specifiers.clone()))
326+
});
327+
let source = WorkspacePythonSource::RequiresPython;
328+
(source, request)
249329
};
250330

251331
Ok(Self {
332+
source,
252333
python_request,
253334
requires_python,
254335
})
@@ -269,6 +350,7 @@ impl FoundInterpreter {
269350
) -> Result<Self, ProjectError> {
270351
// Resolve the Python request and requirement for the workspace.
271352
let WorkspacePython {
353+
source,
272354
python_request,
273355
requires_python,
274356
} = WorkspacePython::from_request(python_request, workspace).await?;
@@ -346,7 +428,7 @@ impl FoundInterpreter {
346428
}
347429

348430
if let Some(requires_python) = requires_python.as_ref() {
349-
validate_requires_python(&interpreter, workspace, requires_python)?;
431+
validate_requires_python(&interpreter, workspace, requires_python, &source)?;
350432
}
351433

352434
Ok(Self::Interpreter(interpreter))

crates/uv/src/commands/project/run.rs

+7-1
Original file line numberDiff line numberDiff line change
@@ -358,6 +358,7 @@ pub(crate) async fn run(
358358

359359
// Resolve the Python request and requirement for the workspace.
360360
let WorkspacePython {
361+
source,
361362
python_request,
362363
requires_python,
363364
} = WorkspacePython::from_request(
@@ -379,7 +380,12 @@ pub(crate) async fn run(
379380
.into_interpreter();
380381

381382
if let Some(requires_python) = requires_python.as_ref() {
382-
validate_requires_python(&interpreter, project.workspace(), requires_python)?;
383+
validate_requires_python(
384+
&interpreter,
385+
project.workspace(),
386+
requires_python,
387+
&source,
388+
)?;
383389
}
384390

385391
// Create a virtual environment

crates/uv/tests/lock.rs

+47
Original file line numberDiff line numberDiff line change
@@ -12581,3 +12581,50 @@ fn lock_strip_fragment() -> Result<()> {
1258112581

1258212582
Ok(())
1258312583
}
12584+
12585+
#[test]
12586+
fn lock_request_requires_python() -> Result<()> {
12587+
let context = TestContext::new("3.12");
12588+
12589+
let pyproject_toml = context.temp_dir.child("pyproject.toml");
12590+
pyproject_toml.write_str(
12591+
r#"
12592+
[project]
12593+
name = "project"
12594+
version = "0.1.0"
12595+
requires-python = ">=3.8, <=3.10"
12596+
dependencies = ["iniconfig"]
12597+
12598+
[build-system]
12599+
requires = ["setuptools>=42"]
12600+
build-backend = "setuptools.build_meta"
12601+
"#,
12602+
)?;
12603+
12604+
// Request a version that conflicts with `--requires-python`.
12605+
uv_snapshot!(context.filters(), context.lock().arg("--python").arg("3.12"), @r###"
12606+
success: false
12607+
exit_code: 2
12608+
----- stdout -----
12609+
12610+
----- stderr -----
12611+
Using Python 3.12.[X] interpreter at: [PYTHON-3.12]
12612+
error: The requested Python interpreter resolved to Python 3.12.[X], which is incompatible with the project's Python requirement: `>=3.8, <=3.10`
12613+
"###);
12614+
12615+
// Add a `.python-version` file that conflicts.
12616+
let python_version = context.temp_dir.child(".python-version");
12617+
python_version.write_str("3.12")?;
12618+
12619+
uv_snapshot!(context.filters(), context.lock(), @r###"
12620+
success: false
12621+
exit_code: 2
12622+
----- stdout -----
12623+
12624+
----- stderr -----
12625+
Using Python 3.12.[X] interpreter at: [PYTHON-3.12]
12626+
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`
12627+
"###);
12628+
12629+
Ok(())
12630+
}

crates/uv/tests/run.rs

+3-3
Original file line numberDiff line numberDiff line change
@@ -133,7 +133,7 @@ fn run_with_python_version() -> Result<()> {
133133
134134
----- stderr -----
135135
Using Python 3.8.[X] interpreter at: [PYTHON-3.8]
136-
error: The requested Python interpreter (3.8.[X]) is incompatible with the project Python requirement: `>=3.11, <4`
136+
error: The requested Python interpreter resolved to Python 3.8.[X], which is incompatible with the project's Python requirement: `>=3.11, <4`
137137
"###);
138138

139139
Ok(())
@@ -1657,7 +1657,7 @@ fn run_isolated_incompatible_python() -> Result<()> {
16571657
16581658
----- stderr -----
16591659
Using Python 3.8.[X] interpreter at: [PYTHON-3.8]
1660-
error: The requested Python interpreter (3.8.[X]) is incompatible with the project Python requirement: `>=3.12`
1660+
error: The Python request from `.python-version` resolved to Python 3.8.[X], which incompatible with the project's Python requirement: `>=3.12`
16611661
"###);
16621662

16631663
// ...even if `--isolated` is provided.
@@ -1667,7 +1667,7 @@ fn run_isolated_incompatible_python() -> Result<()> {
16671667
----- stdout -----
16681668
16691669
----- stderr -----
1670-
error: The requested Python interpreter (3.8.[X]) is incompatible with the project Python requirement: `>=3.12`
1670+
error: The Python request from `.python-version` resolved to Python 3.8.[X], which incompatible with the project's Python requirement: `>=3.12`
16711671
"###);
16721672

16731673
Ok(())

crates/uv/tests/sync.rs

+1-1
Original file line numberDiff line numberDiff line change
@@ -378,7 +378,7 @@ fn mixed_requires_python() -> Result<()> {
378378
379379
----- stderr -----
380380
Using Python 3.8.[X] interpreter at: [PYTHON-3.8]
381-
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 .`.
381+
error: The requested Python 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 .`.
382382
"###);
383383

384384
Ok(())

0 commit comments

Comments
 (0)