From 427e63eccf0f8080ed782df06ec4250c2d28d779 Mon Sep 17 00:00:00 2001 From: Max Mynter Date: Thu, 5 Jun 2025 21:59:09 +0200 Subject: [PATCH 1/6] support python@latest for newest, stable, available python --- crates/uv-python/src/discovery.rs | 23 ++++++- crates/uv-python/src/downloads.rs | 4 +- crates/uv-python/src/lib.rs | 100 +++++++++++++++++++++++++++++ crates/uv-python/src/managed.rs | 1 + crates/uv/src/commands/tool/run.rs | 8 +-- crates/uv/tests/it/tool_run.rs | 12 ++-- 6 files changed, 132 insertions(+), 16 deletions(-) diff --git a/crates/uv-python/src/discovery.rs b/crates/uv-python/src/discovery.rs index 3c301b7c7..d114ab5a7 100644 --- a/crates/uv-python/src/discovery.rs +++ b/crates/uv-python/src/discovery.rs @@ -51,6 +51,8 @@ pub enum PythonRequest { /// Any Python installation Any, /// A Python version without an implementation name e.g. `3.10` or `>=3.12,<3.13` + Latest, + // Newest resolvable, stable Python version. Version(VersionRequest), /// A path to a directory containing a Python installation, e.g. `.venv` Directory(PathBuf), @@ -1041,6 +1043,19 @@ pub fn find_python_installations<'a>( .map_ok(|tuple| Ok(PythonInstallation::from_tuple(tuple))) }) } + PythonRequest::Latest => Box::new({ + debug!("Searching for latest Python interpreter in {sources}"); + python_interpreters(&VersionRequest::Any, None, environments, preference, cache) + .filter_ok(|(_source, interpreter)| interpreter.python_version().pre().is_none()) + .max_by_key(|result| { + result + .as_ref() + .map(|(_, interpreter)| interpreter.python_version().clone()) + .ok() + }) + .into_iter() + .map_ok(|tuple| Ok(PythonInstallation::from_tuple(tuple))) + }), } } @@ -1493,7 +1508,7 @@ impl PythonRequest { } match self { - PythonRequest::Default | PythonRequest::Any => true, + PythonRequest::Default | PythonRequest::Any | PythonRequest::Latest => true, PythonRequest::Version(version_request) => { version_request.matches_interpreter(interpreter) } @@ -1582,7 +1597,7 @@ impl PythonRequest { /// Whether this request opts-in to a pre-release Python version. pub(crate) fn allows_prereleases(&self) -> bool { match self { - Self::Default => false, + Self::Default | Self::Latest => false, Self::Any => true, Self::Version(version) => version.allows_prereleases(), Self::Directory(_) | Self::File(_) | Self::ExecutableName(_) => true, @@ -1595,7 +1610,7 @@ impl PythonRequest { /// Whether this request opts-in to an alternative Python implementation, e.g., PyPy. pub(crate) fn allows_alternative_implementations(&self) -> bool { match self { - Self::Default => false, + Self::Default | Self::Latest => false, Self::Any => true, Self::Version(_) => false, Self::Directory(_) | Self::File(_) | Self::ExecutableName(_) => true, @@ -1616,6 +1631,7 @@ impl PythonRequest { match self { Self::Any => "any".to_string(), Self::Default => "default".to_string(), + Self::Latest => "latest".to_string(), Self::Version(version) => version.to_string(), Self::Directory(path) => path.display().to_string(), Self::File(path) => path.display().to_string(), @@ -2518,6 +2534,7 @@ impl fmt::Display for PythonRequest { match self { Self::Default => write!(f, "a default Python"), Self::Any => write!(f, "any Python"), + Self::Latest => write!(f, "latest stable Python"), Self::Version(version) => write!(f, "Python {version}"), Self::Directory(path) => write!(f, "directory `{}`", path.user_display()), Self::File(path) => write!(f, "path `{}`", path.user_display()), diff --git a/crates/uv-python/src/downloads.rs b/crates/uv-python/src/downloads.rs index f97a8fc2b..df412c029 100644 --- a/crates/uv-python/src/downloads.rs +++ b/crates/uv-python/src/downloads.rs @@ -195,7 +195,9 @@ impl PythonDownloadRequest { .with_version(version.clone()), ), PythonRequest::Key(request) => Some(request.clone()), - PythonRequest::Default | PythonRequest::Any => Some(Self::default()), + PythonRequest::Default | PythonRequest::Any | PythonRequest::Latest => { + Some(Self::default()) + } // We can't download a managed installation for these request kinds PythonRequest::Directory(_) | PythonRequest::ExecutableName(_) diff --git a/crates/uv-python/src/lib.rs b/crates/uv-python/src/lib.rs index ca723db6d..5adc42719 100644 --- a/crates/uv-python/src/lib.rs +++ b/crates/uv-python/src/lib.rs @@ -1033,6 +1033,106 @@ mod tests { Ok(()) } + #[test] + fn find_python_latest() -> Result<()> { + let mut context = TestContext::new()?; + context.add_python_versions(&["3.8.10", "3.11.5", "3.9.18", "3.10.12"])?; + + let python = context.run(|| { + find_python_installation( + &PythonRequest::Latest, + EnvironmentPreference::Any, + PythonPreference::OnlySystem, + &context.cache, + ) + })??; + + assert!( + matches!( + python, + PythonInstallation { + source: PythonSource::SearchPath, + interpreter: _ + } + ), + "We should find a python; got {python:?}" + ); + assert_eq!( + &python.interpreter().python_full_version().to_string(), + "3.11.5", + "We should find the latest version (3.11.5)" + ); + + Ok(()) + } + + #[test] + fn find_python_latest_with_prereleases() -> Result<()> { + let mut context = TestContext::new()?; + context.add_python_versions(&["3.10.1", "3.11.2", "3.12.0rc1", "3.13.0a1"])?; + + let python = context.run(|| { + find_python_installation( + &PythonRequest::Latest, + EnvironmentPreference::Any, + PythonPreference::OnlySystem, + &context.cache, + ) + })??; + + assert_eq!( + &python.interpreter().python_full_version().to_string(), + "3.11.2", + "Latest should find the highest stable version" + ); + + Ok(()) + } + + #[test] + fn find_python_latest_no_pythons() -> Result<()> { + let context = TestContext::new()?; + // Don't add any Python versions + + let result = context.run(|| { + find_python_installation( + &PythonRequest::Latest, + EnvironmentPreference::Any, + PythonPreference::OnlySystem, + &context.cache, + ) + })?; + + assert!( + matches!(result, Err(PythonNotFound { .. })), + "We should not find any python when none are available; got {result:?}" + ); + + Ok(()) + } + + #[test] + fn find_python_latest_only_prereleases() -> Result<()> { + let mut context = TestContext::new()?; + context.add_python_versions(&["3.12.0rc1", "3.13.0a1", "3.11.0b2"])?; + + let result = context.run(|| { + find_python_installation( + &PythonRequest::Latest, + EnvironmentPreference::Any, + PythonPreference::OnlySystem, + &context.cache, + ) + })?; + + assert!( + matches!(result, Err(PythonNotFound { .. })), + "We should not find any python when only prereleases are available; got {result:?}" + ); + + Ok(()) + } + #[test] fn find_python_from_conda_prefix() -> Result<()> { let context = TestContext::new()?; diff --git a/crates/uv-python/src/managed.rs b/crates/uv-python/src/managed.rs index 343c4ad74..699355413 100644 --- a/crates/uv-python/src/managed.rs +++ b/crates/uv-python/src/managed.rs @@ -458,6 +458,7 @@ impl ManagedPythonInstallation { } PythonRequest::Version(version) => version.matches_version(&self.version()), PythonRequest::Key(request) => request.satisfied_by_key(self.key()), + PythonRequest::Latest => true, } } diff --git a/crates/uv/src/commands/tool/run.rs b/crates/uv/src/commands/tool/run.rs index 7d4cbc174..34968abc4 100644 --- a/crates/uv/src/commands/tool/run.rs +++ b/crates/uv/src/commands/tool/run.rs @@ -636,13 +636,7 @@ async fn get_or_create_environment( Target::Version(_, _, _, version) => Some(PythonRequest::Version( VersionRequest::from_str(&version.to_string()).map_err(anyhow::Error::from)?, )), - // TODO(zanieb): Add `PythonRequest::Latest` - Target::Latest(_, _, _) => { - return Err(anyhow::anyhow!( - "Requesting the 'latest' Python version is not yet supported" - ) - .into()) - } + Target::Latest(_, _, _) => Some(PythonRequest::Latest), }; if let Some(target_request) = &target_request { diff --git a/crates/uv/tests/it/tool_run.rs b/crates/uv/tests/it/tool_run.rs index 59d28f559..e43aabc6c 100644 --- a/crates/uv/tests/it/tool_run.rs +++ b/crates/uv/tests/it/tool_run.rs @@ -1952,14 +1952,16 @@ fn tool_run_python_at_version() { // Request `@latest` (not yet supported) uv_snapshot!(context.filters(), context.tool_run() .arg("python@latest") - .arg("--version"), @r###" - success: false - exit_code: 2 + .arg("--version"), @r" + success: true + exit_code: 0 ----- stdout ----- + Python 3.12.[X] ----- stderr ----- - error: Requesting the 'latest' Python version is not yet supported - "###); + Resolved in [TIME] + Audited in [TIME] + "); } #[test] From a372aac6a99da2612dc9ec9ab086286510cf9fdb Mon Sep 17 00:00:00 2001 From: Max Mynter Date: Fri, 6 Jun 2025 15:44:53 +0200 Subject: [PATCH 2/6] (fixup) Integrate Latest Python version support into Main --- crates/uv-python/src/discovery.rs | 31 ++++++++++++++++++------------- 1 file changed, 18 insertions(+), 13 deletions(-) diff --git a/crates/uv-python/src/discovery.rs b/crates/uv-python/src/discovery.rs index 803df3a46..55d09b055 100644 --- a/crates/uv-python/src/discovery.rs +++ b/crates/uv-python/src/discovery.rs @@ -162,6 +162,7 @@ pub enum VersionRequest { Default, /// Allow any Python version. Any, + Latest, Major(u8, PythonVariant), MajorMinor(u8, u8, PythonVariant), MajorMinorPatch(u8, u8, u8, PythonVariant), @@ -574,6 +575,7 @@ fn find_all_minor( match version_request { &VersionRequest::Any | VersionRequest::Default + | VersionRequest::Latest | VersionRequest::Major(_, _) | VersionRequest::Range(_, _) => { let regex = if let Some(implementation) = implementation { @@ -1599,9 +1601,7 @@ impl PythonRequest { // parsing errors are raised to the caller. if let Some(after_at) = rest.strip_prefix('@') { if after_at == "latest" { - // Handle `@latest` as a special case. It's still an error for now, but we plan to - // support it. TODO(zanieb): Add `PythonRequest::Latest` - return Err(Error::LatestVersionRequest); + return Ok(Some(VersionRequest::Latest)); } return after_at.parse().map(Some); } @@ -2142,7 +2142,7 @@ impl VersionRequest { /// Return the major version segment of the request, if any. pub(crate) fn major(&self) -> Option { match self { - Self::Any | Self::Default | Self::Range(_, _) => None, + Self::Any | Self::Default | Self::Latest | Self::Range(_, _) => None, Self::Major(major, _) => Some(*major), Self::MajorMinor(major, _, _) => Some(*major), Self::MajorMinorPatch(major, _, _, _) => Some(*major), @@ -2153,7 +2153,7 @@ impl VersionRequest { /// Return the minor version segment of the request, if any. pub(crate) fn minor(&self) -> Option { match self { - Self::Any | Self::Default | Self::Range(_, _) => None, + Self::Any | Self::Default | Self::Latest | Self::Range(_, _) => None, Self::Major(_, _) => None, Self::MajorMinor(_, minor, _) => Some(*minor), Self::MajorMinorPatch(_, minor, _, _) => Some(*minor), @@ -2164,7 +2164,7 @@ impl VersionRequest { /// Return the patch version segment of the request, if any. pub(crate) fn patch(&self) -> Option { match self { - Self::Any | Self::Default | Self::Range(_, _) => None, + Self::Any | Self::Default | Self::Latest | Self::Range(_, _) => None, Self::Major(_, _) => None, Self::MajorMinor(_, _, _) => None, Self::MajorMinorPatch(_, _, patch, _) => Some(*patch), @@ -2177,7 +2177,7 @@ impl VersionRequest { /// If not, an `Err` is returned with an explanatory message. pub(crate) fn check_supported(&self) -> Result<(), String> { match self { - Self::Any | Self::Default => (), + Self::Any | Self::Latest | Self::Default => (), Self::Major(major, _) => { if *major < 3 { return Err(format!( @@ -2252,6 +2252,7 @@ impl VersionRequest { pub(crate) fn matches_interpreter(&self, interpreter: &Interpreter) -> bool { match self { Self::Any => true, + Self::Latest => interpreter.python_version().pre().is_none(), // Do not use free-threaded interpreters by default Self::Default => PythonVariant::Default.matches_interpreter(interpreter), Self::Major(major, variant) => { @@ -2295,6 +2296,7 @@ impl VersionRequest { pub(crate) fn matches_version(&self, version: &PythonVersion) -> bool { match self { Self::Any | Self::Default => true, + Self::Latest => version.pre().is_none(), Self::Major(major, _) => version.major() == *major, Self::MajorMinor(major, minor, _) => { (version.major(), version.minor()) == (*major, *minor) @@ -2317,7 +2319,7 @@ impl VersionRequest { /// avoid querying interpreters if it's clear it cannot fulfill the request. fn matches_major_minor(&self, major: u8, minor: u8) -> bool { match self { - Self::Any | Self::Default => true, + Self::Any | Self::Latest | Self::Default => true, Self::Major(self_major, _) => *self_major == major, Self::MajorMinor(self_major, self_minor, _) => { (*self_major, *self_minor) == (major, minor) @@ -2364,6 +2366,7 @@ impl VersionRequest { ) -> bool { match self { Self::Any | Self::Default => true, + Self::Latest => prerelease.is_none(), Self::Major(self_major, _) => *self_major == major, Self::MajorMinor(self_major, self_minor, _) => { (*self_major, *self_minor) == (major, minor) @@ -2386,7 +2389,7 @@ impl VersionRequest { /// Whether a patch version segment is present in the request. fn has_patch(&self) -> bool { match self { - Self::Any | Self::Default => false, + Self::Any | Self::Default | Self::Latest => false, Self::Major(..) => false, Self::MajorMinor(..) => false, Self::MajorMinorPatch(..) => true, @@ -2403,6 +2406,7 @@ impl VersionRequest { match self { Self::Default => Self::Default, Self::Any => Self::Any, + Self::Latest => Self::Latest, Self::Major(major, variant) => Self::Major(major, variant), Self::MajorMinor(major, minor, variant) => Self::MajorMinor(major, minor, variant), Self::MajorMinorPatch(major, minor, _, variant) => { @@ -2418,7 +2422,7 @@ impl VersionRequest { /// Whether this request should allow selection of pre-release versions. pub(crate) fn allows_prereleases(&self) -> bool { match self { - Self::Default => false, + Self::Default | Self::Latest => false, Self::Any => true, Self::Major(..) => false, Self::MajorMinor(..) => false, @@ -2431,7 +2435,7 @@ impl VersionRequest { /// Whether this request is for a free-threaded Python variant. pub(crate) fn is_freethreaded(&self) -> bool { match self { - Self::Any | Self::Default => false, + Self::Any | Self::Default | Self::Latest => false, Self::Major(_, variant) | Self::MajorMinor(_, _, variant) | Self::MajorMinorPatch(_, _, _, variant) @@ -2448,7 +2452,7 @@ impl VersionRequest { // TODO(zanieb): Replace this entire function with a utility that casts this to a version // without using `VersionRequest::to_string`. match self { - Self::Any | Self::Default => self, + Self::Any | Self::Default | Self::Latest => self, Self::Major(major, _) => Self::Major(major, PythonVariant::Default), Self::MajorMinor(major, minor, _) => { Self::MajorMinor(major, minor, PythonVariant::Default) @@ -2467,7 +2471,7 @@ impl VersionRequest { pub(crate) fn variant(&self) -> Option { match self { Self::Any => None, - Self::Default => Some(PythonVariant::Default), + Self::Default | Self::Latest => Some(PythonVariant::Default), Self::Major(_, variant) | Self::MajorMinor(_, _, variant) | Self::MajorMinorPatch(_, _, _, variant) @@ -2622,6 +2626,7 @@ impl fmt::Display for VersionRequest { match self { Self::Any => f.write_str("any"), Self::Default => f.write_str("default"), + Self::Latest => f.write_str("latest"), Self::Major(major, PythonVariant::Default) => write!(f, "{major}"), Self::Major(major, PythonVariant::Freethreaded) => write!(f, "{major}t"), Self::MajorMinor(major, minor, PythonVariant::Default) => write!(f, "{major}.{minor}"), From f469db11bf17224870d78f65a62286e6582131f5 Mon Sep 17 00:00:00 2001 From: Max Mynter Date: Fri, 6 Jun 2025 23:33:01 +0200 Subject: [PATCH 3/6] Update Snapshot --- crates/uv/tests/it/tool_run.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/crates/uv/tests/it/tool_run.rs b/crates/uv/tests/it/tool_run.rs index 16bd4de73..bf9b274e9 100644 --- a/crates/uv/tests/it/tool_run.rs +++ b/crates/uv/tests/it/tool_run.rs @@ -2047,7 +2047,6 @@ fn tool_run_python_at_version() { ----- stderr ----- Resolved in [TIME] - Audited in [TIME] "); } From 5a45d4ee85611c8975938cceba3ebfa0571a79c7 Mon Sep 17 00:00:00 2001 From: Max Mynter Date: Sat, 7 Jun 2025 01:11:55 +0200 Subject: [PATCH 4/6] Sort using Ord on key --- crates/uv-python/src/discovery.rs | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/crates/uv-python/src/discovery.rs b/crates/uv-python/src/discovery.rs index 55d09b055..999c70ee2 100644 --- a/crates/uv-python/src/discovery.rs +++ b/crates/uv-python/src/discovery.rs @@ -1059,11 +1059,9 @@ pub fn find_python_installations<'a>( debug!("Searching for latest Python interpreter in {sources}"); python_interpreters(&VersionRequest::Any, None, environments, preference, cache) .filter_ok(|(_source, interpreter)| interpreter.python_version().pre().is_none()) - .max_by_key(|result| { - result - .as_ref() - .map(|(_, interpreter)| interpreter.python_version().clone()) - .ok() + .max_by(|a, b| match (a.as_ref(), b.as_ref()) { + (Ok((_, interp_a)), Ok((_, interp_b))) => interp_a.key().cmp(&interp_b.key()), + _ => std::cmp::Ordering::Equal, }) .into_iter() .map_ok(|tuple| Ok(PythonInstallation::from_tuple(tuple))) From 2a5f10c296dce3bd6e7f69f8ac962a89c937443c Mon Sep 17 00:00:00 2001 From: Max Mynter Date: Sat, 7 Jun 2025 01:19:33 +0200 Subject: [PATCH 5/6] Update example in strip t comment latest is now supported. --- crates/uv-python/src/discovery.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/uv-python/src/discovery.rs b/crates/uv-python/src/discovery.rs index 999c70ee2..e6ce19cfd 100644 --- a/crates/uv-python/src/discovery.rs +++ b/crates/uv-python/src/discovery.rs @@ -2484,7 +2484,7 @@ impl FromStr for VersionRequest { fn from_str(s: &str) -> Result { // Stripping the 't' suffix produces awkward error messages if the user tries a version - // like "latest". HACK: If the version is all letters, don't even try to parse it further. + // like "oldest". HACK: If the version is all letters, don't even try to parse it further. if s.chars().all(char::is_alphabetic) { return Err(Error::InvalidVersionRequest(s.to_string())); } From c07e98de745773cbb636d146f69dce77b1ea8a18 Mon Sep 17 00:00:00 2001 From: Max Mynter Date: Sat, 7 Jun 2025 01:42:32 +0200 Subject: [PATCH 6/6] fix sloppy naming --- crates/uv-python/src/discovery.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/uv-python/src/discovery.rs b/crates/uv-python/src/discovery.rs index e6ce19cfd..416034d9b 100644 --- a/crates/uv-python/src/discovery.rs +++ b/crates/uv-python/src/discovery.rs @@ -1060,7 +1060,9 @@ pub fn find_python_installations<'a>( python_interpreters(&VersionRequest::Any, None, environments, preference, cache) .filter_ok(|(_source, interpreter)| interpreter.python_version().pre().is_none()) .max_by(|a, b| match (a.as_ref(), b.as_ref()) { - (Ok((_, interp_a)), Ok((_, interp_b))) => interp_a.key().cmp(&interp_b.key()), + (Ok((_, interpreter_a)), Ok((_, interpreter_b))) => { + interpreter_a.key().cmp(&interpreter_b.key()) + } _ => std::cmp::Ordering::Equal, }) .into_iter()