@@ -7,9 +7,10 @@ use anyhow::{Context, Result};
77use mea:: once:: OnceMap ;
88use prek_consts:: env_vars:: EnvVars ;
99use prek_consts:: prepend_paths;
10+ use regex:: Regex ;
1011use rustc_hash:: FxBuildHasher ;
1112use serde:: Deserialize ;
12- use tracing:: { debug, trace } ;
13+ use tracing:: debug;
1314
1415use crate :: cli:: reporter:: HookInstallReporter ;
1516use 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 ) ]
242260enum 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}
0 commit comments