Skip to content

Commit 4eb0dbb

Browse files
committed
Retry uv pip install with an inferred Python constraint
When `uv pip install` fails to resolve because the venv's Python is too old for a (possibly transitive) dependency, parse the required Python version from uv's error, recreate the venv with that constraint, and retry once. Closes #1618.
1 parent 928dc5c commit 4eb0dbb

2 files changed

Lines changed: 151 additions & 28 deletions

File tree

crates/prek/src/languages/python/python.rs

Lines changed: 146 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,10 @@ use anyhow::{Context, Result};
77
use mea::once::OnceMap;
88
use prek_consts::env_vars::EnvVars;
99
use prek_consts::prepend_paths;
10+
use regex::Regex;
1011
use rustc_hash::FxBuildHasher;
1112
use serde::Deserialize;
12-
use tracing::{debug, trace};
13+
use tracing::debug;
1314

1415
use crate::cli::reporter::HookInstallReporter;
1516
use crate::cli::run::HookRunReporter;
@@ -119,32 +120,7 @@ impl LanguageBackend for Python {
119120
.context("Failed to create Python virtual environment")?;
120121

121122
// Install dependencies
122-
let mut pip_install = Self::pip_install_command(&uv, store, &info.env_path);
123-
124-
if let Some(repo_path) = hook.repo_path() {
125-
trace!(
126-
"Installing dependencies from repo path: {}",
127-
repo_path.display()
128-
);
129-
pip_install
130-
.arg("--directory")
131-
.arg(repo_path)
132-
.arg(".")
133-
.args(&hook.additional_dependencies)
134-
.output()
135-
.await?;
136-
} else if !hook.additional_dependencies.is_empty() {
137-
trace!(
138-
"Installing additional dependencies: {:?}",
139-
hook.additional_dependencies
140-
);
141-
pip_install
142-
.args(&hook.additional_dependencies)
143-
.output()
144-
.await?;
145-
} else {
146-
debug!("No dependencies to install");
147-
}
123+
Self::install_dependencies(&uv, store, &info, &hook, &hook.language_request).await?;
148124

149125
let python = python_exec(&info.env_path);
150126
let python_info = query_python_info(&python)
@@ -238,6 +214,48 @@ fn to_uv_python_request(request: &LanguageRequest) -> Option<String> {
238214
}
239215
}
240216

217+
/// The highest `requires Python >=X.Y` bound in a uv resolution failure, as a
218+
/// `major.minor` request (the bound is a minimum, so the patch is dropped).
219+
fn infer_python_request(stderr: &[u8]) -> Option<LanguageRequest> {
220+
static PYTHON_BOUND: LazyLock<Regex> =
221+
LazyLock::new(|| Regex::new(r"Python\s*>=?\s*(\d+\.\d+(?:\.\d+)?)").unwrap());
222+
223+
let stderr = String::from_utf8_lossy(stderr);
224+
let (major, minor, _) = PYTHON_BOUND
225+
.captures_iter(&stderr)
226+
.filter_map(|caps| Some(version_sort_key(caps.get(1)?.as_str())))
227+
.max()?;
228+
229+
Some(LanguageRequest::Python(PythonRequest::MajorMinor(
230+
major, minor,
231+
)))
232+
}
233+
234+
/// Whether `original` allows the `inferred` interpreter. The inferred request is always a
235+
/// `major.minor` from a uv `Requires-Python` bound; the default (`Any`) permits anything.
236+
fn request_permits(original: &LanguageRequest, inferred: &LanguageRequest) -> bool {
237+
let LanguageRequest::Python(PythonRequest::MajorMinor(major, minor)) = inferred else {
238+
return false;
239+
};
240+
match original {
241+
LanguageRequest::Any { .. } => true,
242+
LanguageRequest::Python(request) => {
243+
request.permits(&semver::Version::new(*major, *minor, 0))
244+
}
245+
_ => false,
246+
}
247+
}
248+
249+
/// Sort key for a `major.minor[.patch]` version string; unparsable parts sort as 0.
250+
fn version_sort_key(version: &str) -> (u64, u64, u64) {
251+
let mut parts = version.split('.').map(|p| p.parse::<u64>().unwrap_or(0));
252+
(
253+
parts.next().unwrap_or(0),
254+
parts.next().unwrap_or(0),
255+
parts.next().unwrap_or(0),
256+
)
257+
}
258+
241259
#[derive(Debug, Clone, Copy)]
242260
enum VenvAttempt {
243261
PrekManaged,
@@ -267,6 +285,64 @@ impl Python {
267285
cmd
268286
}
269287

288+
/// Install the hook's dependencies, retrying once with a newer venv Python if `uv`
289+
/// fails to resolve because the current one is too old for a (transitive)
290+
/// dependency. On no inferable version, the original error is surfaced.
291+
async fn install_dependencies(
292+
uv: &Uv,
293+
store: &Store,
294+
info: &InstallInfo,
295+
hook: &Hook,
296+
python_request: &LanguageRequest,
297+
) -> Result<()> {
298+
if hook.repo_path().is_none() && hook.additional_dependencies.is_empty() {
299+
debug!("No dependencies to install");
300+
return Ok(());
301+
}
302+
303+
let build = || {
304+
let mut cmd = Self::pip_install_command(uv, store, &info.env_path);
305+
if let Some(repo_path) = hook.repo_path() {
306+
cmd.arg("--directory").arg(repo_path).arg(".");
307+
}
308+
cmd.args(&hook.additional_dependencies);
309+
cmd
310+
};
311+
312+
// Capture the failure instead of bailing, so we can inspect and maybe retry.
313+
let mut cmd = build();
314+
let output = cmd.check(false).output().await?;
315+
if output.status.success() {
316+
return Ok(());
317+
}
318+
319+
// Retry only with a different version the original request still permits (so we never
320+
// install an interpreter the user ruled out), and never download for a `system` request.
321+
let retry_request = infer_python_request(&output.stderr)
322+
.filter(|request| request != python_request)
323+
.filter(|_| python_request.allows_download())
324+
.filter(|request| request_permits(python_request, request));
325+
326+
let Some(retry_request) = retry_request else {
327+
cmd.check_output(output)?;
328+
return Ok(());
329+
};
330+
331+
// Preserve the original resolution error if the venv recreate fails.
332+
let original_error = String::from_utf8_lossy(&output.stderr).into_owned();
333+
debug!("uv pip install failed to resolve; retrying with a newer Python");
334+
Self::create_venv(uv, store, info, &retry_request)
335+
.await
336+
.with_context(|| {
337+
format!(
338+
"Failed to recreate the venv with the inferred Python version.\n\
339+
Original dependency resolution error:\n{original_error}"
340+
)
341+
})?;
342+
build().check(true).output().await?;
343+
Ok(())
344+
}
345+
270346
async fn create_venv(
271347
uv: &Uv,
272348
store: &Store,
@@ -543,4 +619,47 @@ mod tests {
543619
assert_eq!(envs.get(EnvVars::UV_SYSTEM_PYTHON), Some(&None));
544620
assert_eq!(envs.get(EnvVars::UV_PYTHON), Some(&None));
545621
}
622+
623+
#[test]
624+
fn infer_python_request_picks_highest_bound() {
625+
use super::{infer_python_request, to_uv_python_request};
626+
627+
// No Python bound in the error -> nothing to refine.
628+
assert!(infer_python_request(b"error: something unrelated failed").is_none());
629+
630+
// A single bound.
631+
let req = infer_python_request(b"Because foo requires Python >=3.10, ...").unwrap();
632+
assert_eq!(to_uv_python_request(&req).as_deref(), Some("3.10"));
633+
634+
// Multiple bounds -> the highest wins (and beats lexical: 3.9 < 3.10). The
635+
// patch is dropped since the bound is a minimum, so `>=3.11.2` -> `3.11`.
636+
let req =
637+
infer_python_request(b"requires Python >=3.9 and bar requires Python>=3.11.2 so ...")
638+
.unwrap();
639+
assert_eq!(to_uv_python_request(&req).as_deref(), Some("3.11"));
640+
}
641+
642+
#[test]
643+
fn request_permits_respects_explicit_requests() {
644+
use super::request_permits;
645+
use crate::languages::python::PythonRequest;
646+
647+
let inferred = LanguageRequest::Python(PythonRequest::MajorMinor(3, 11));
648+
649+
// The default request allows any inferred interpreter.
650+
assert!(request_permits(
651+
&LanguageRequest::Any { system_only: false },
652+
&inferred
653+
));
654+
655+
// An open range that still covers the inferred version.
656+
let range = LanguageRequest::Python(">=3.8".parse::<PythonRequest>().unwrap());
657+
assert!(request_permits(&range, &inferred));
658+
659+
// Requests that rule the inferred version out are not retried.
660+
let pinned = LanguageRequest::Python(PythonRequest::MajorMinor(3, 9));
661+
assert!(!request_permits(&pinned, &inferred));
662+
let capped = LanguageRequest::Python(">=3.8, <3.11".parse::<PythonRequest>().unwrap());
663+
assert!(!request_permits(&capped, &inferred));
664+
}
546665
}

crates/prek/src/languages/python/version.rs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,11 @@ impl PythonRequest {
7676
}
7777

7878
pub(crate) fn satisfied_by(&self, install_info: &InstallInfo) -> bool {
79-
let version = &install_info.language_version;
79+
self.permits(&install_info.language_version)
80+
}
81+
82+
/// Whether a concrete interpreter version is allowed by this request.
83+
pub(crate) fn permits(&self, version: &semver::Version) -> bool {
8084
match self {
8185
PythonRequest::Any => true,
8286
PythonRequest::Major(major) => version.major == *major,

0 commit comments

Comments
 (0)