diff --git a/crates/uv-cli/src/lib.rs b/crates/uv-cli/src/lib.rs index 2620a9e7d..c687b0bd8 100644 --- a/crates/uv-cli/src/lib.rs +++ b/crates/uv-cli/src/lib.rs @@ -2169,6 +2169,9 @@ pub enum PythonCommand { /// Search for a Python installation. Find(PythonFindArgs), + /// Pin to a specific Python version. + Pin(PythonPinArgs), + /// Show the uv Python installation directory. Dir, @@ -2226,6 +2229,22 @@ pub struct PythonFindArgs { pub request: Option, } +#[derive(Args)] +#[allow(clippy::struct_excessive_bools)] +pub struct PythonPinArgs { + /// The Python version. + pub request: Option, + + /// Write the resolved Python interpreter path instead of the request. + /// + /// Ensures that the exact same interpreter is used. + #[arg(long, overrides_with("resolved"))] + pub resolved: bool, + + #[arg(long, overrides_with("no_resolved"), hide = true)] + pub no_resolved: bool, +} + #[derive(Args)] #[allow(clippy::struct_excessive_bools)] pub struct IndexArgs { diff --git a/crates/uv/src/commands/mod.rs b/crates/uv/src/commands/mod.rs index 9d73b8b71..517a7d27d 100644 --- a/crates/uv/src/commands/mod.rs +++ b/crates/uv/src/commands/mod.rs @@ -28,6 +28,7 @@ pub(crate) use python::dir::dir as python_dir; pub(crate) use python::find::find as python_find; pub(crate) use python::install::install as python_install; pub(crate) use python::list::list as python_list; +pub(crate) use python::pin::pin as python_pin; pub(crate) use python::uninstall::uninstall as python_uninstall; #[cfg(feature = "self-update")] pub(crate) use self_update::self_update; diff --git a/crates/uv/src/commands/python/mod.rs b/crates/uv/src/commands/python/mod.rs index 06cda22fb..b63ec59d5 100644 --- a/crates/uv/src/commands/python/mod.rs +++ b/crates/uv/src/commands/python/mod.rs @@ -2,4 +2,5 @@ pub(crate) mod dir; pub(crate) mod find; pub(crate) mod install; pub(crate) mod list; +pub(crate) mod pin; pub(crate) mod uninstall; diff --git a/crates/uv/src/commands/python/pin.rs b/crates/uv/src/commands/python/pin.rs new file mode 100644 index 000000000..b1564b308 --- /dev/null +++ b/crates/uv/src/commands/python/pin.rs @@ -0,0 +1,84 @@ +use std::fmt::Write; +use std::path::PathBuf; + +use anyhow::{bail, Result}; + +use tracing::debug; +use uv_cache::Cache; +use uv_configuration::PreviewMode; +use uv_fs::Simplified; +use uv_python::{ + requests_from_version_file, EnvironmentPreference, PythonInstallation, PythonPreference, + PythonRequest, PYTHON_VERSION_FILENAME, +}; +use uv_warnings::warn_user_once; + +use crate::commands::ExitStatus; +use crate::printer::Printer; + +/// Pin to a specific Python version. +pub(crate) async fn pin( + request: Option, + resolved: bool, + python_preference: PythonPreference, + preview: PreviewMode, + cache: &Cache, + printer: Printer, +) -> Result { + if preview.is_disabled() { + warn_user_once!("`uv python pin` is experimental and may change without warning."); + } + + let Some(request) = request else { + // Display the current pinned Python version + if let Some(pins) = requests_from_version_file().await? { + for pin in pins { + writeln!(printer.stdout(), "{}", pin.to_canonical_string())?; + } + return Ok(ExitStatus::Success); + } + bail!("No pinned Python version found.") + }; + let request = PythonRequest::parse(&request); + + let python = match PythonInstallation::find( + &request, + EnvironmentPreference::OnlySystem, + python_preference, + cache, + ) { + Ok(python) => Some(python), + // If no matching Python version is found, don't fail unless `resolved` was requested + Err(uv_python::Error::MissingPython(err)) if !resolved => { + warn_user_once!("{}", err); + None + } + Err(err) => return Err(err.into()), + }; + + let output = if resolved { + // SAFETY: We exit early if Python is not found and resolved is `true` + python + .unwrap() + .interpreter() + .sys_executable() + .user_display() + .to_string() + } else { + request.to_canonical_string() + }; + + debug!("Using pin `{}`", output); + let version_file = PathBuf::from(PYTHON_VERSION_FILENAME); + let exists = version_file.exists(); + + debug!("Writing pin to {}", version_file.user_display()); + fs_err::write(&version_file, format!("{output}\n"))?; + if exists { + writeln!(printer.stdout(), "Replaced existing pin with `{output}`")?; + } else { + writeln!(printer.stdout(), "Pinned to `{output}`")?; + } + + Ok(ExitStatus::Success) +} diff --git a/crates/uv/src/main.rs b/crates/uv/src/main.rs index 0e33183ff..41035ac80 100644 --- a/crates/uv/src/main.rs +++ b/crates/uv/src/main.rs @@ -809,6 +809,25 @@ async fn run() -> Result { ) .await } + Commands::Python(PythonNamespace { + command: PythonCommand::Pin(args), + }) => { + // Resolve the settings from the command-line arguments and workspace configuration. + let args = settings::PythonPinSettings::resolve(args, filesystem); + + // Initialize the cache. + let cache = cache.init()?; + + commands::python_pin( + args.request, + args.resolved, + globals.python_preference, + globals.preview, + &cache, + printer, + ) + .await + } Commands::Python(PythonNamespace { command: PythonCommand::Dir, }) => { diff --git a/crates/uv/src/settings.rs b/crates/uv/src/settings.rs index 215809156..c4a8663ad 100644 --- a/crates/uv/src/settings.rs +++ b/crates/uv/src/settings.rs @@ -14,8 +14,8 @@ use uv_cli::{ AddArgs, ColorChoice, Commands, ExternalCommand, GlobalArgs, ListFormat, LockArgs, Maybe, PipCheckArgs, PipCompileArgs, PipFreezeArgs, PipInstallArgs, PipListArgs, PipShowArgs, PipSyncArgs, PipTreeArgs, PipUninstallArgs, PythonFindArgs, PythonInstallArgs, PythonListArgs, - PythonUninstallArgs, RemoveArgs, RunArgs, SyncArgs, ToolInstallArgs, ToolListArgs, ToolRunArgs, - ToolUninstallArgs, TreeArgs, VenvArgs, + PythonPinArgs, PythonUninstallArgs, RemoveArgs, RunArgs, SyncArgs, ToolInstallArgs, + ToolListArgs, ToolRunArgs, ToolUninstallArgs, TreeArgs, VenvArgs, }; use uv_client::Connectivity; use uv_configuration::{ @@ -410,6 +410,30 @@ impl PythonFindSettings { } } +/// The resolved settings to use for a `python pin` invocation. +#[allow(clippy::struct_excessive_bools)] +#[derive(Debug, Clone)] +pub(crate) struct PythonPinSettings { + pub(crate) request: Option, + pub(crate) resolved: bool, +} + +impl PythonPinSettings { + /// Resolve the [`PythonPinSettings`] from the CLI and workspace configuration. + #[allow(clippy::needless_pass_by_value)] + pub(crate) fn resolve(args: PythonPinArgs, _filesystem: Option) -> Self { + let PythonPinArgs { + request, + no_resolved, + resolved, + } = args; + + Self { + request, + resolved: flag(resolved, no_resolved).unwrap_or(false), + } + } +} /// The resolved settings to use for a `sync` invocation. #[allow(clippy::struct_excessive_bools, dead_code)] #[derive(Debug, Clone)] diff --git a/crates/uv/tests/common/mod.rs b/crates/uv/tests/common/mod.rs index d44dcd920..01608621f 100644 --- a/crates/uv/tests/common/mod.rs +++ b/crates/uv/tests/common/mod.rs @@ -233,7 +233,12 @@ impl TestContext { filters.extend( Self::path_patterns(&python_dir.join(version.to_string())) .into_iter() - .map(|pattern| (format!("{pattern}.*"), format!("[PYTHON-{version}]"))), + .map(|pattern| { + ( + format!("{pattern}[a-zA-Z0-9]*"), + format!("[PYTHON-{version}]"), + ) + }), ); // Add Python patch version filtering unless explicitly requested to ensure @@ -429,6 +434,19 @@ impl TestContext { command } + /// Create a `uv python pin` command with options shared across scenarios. + pub fn python_pin(&self) -> Command { + let mut command = Command::new(get_bin()); + command + .arg("python") + .arg("pin") + .env("UV_PREVIEW", "1") + .env("UV_PYTHON_INSTALL_DIR", "") + .current_dir(&self.temp_dir); + self.add_shared_args(&mut command); + command + } + /// Create a `uv python dir` command with options shared across scenarios. pub fn python_dir(&self) -> Command { let mut command = Command::new(get_bin()); diff --git a/crates/uv/tests/help.rs b/crates/uv/tests/help.rs index 7fce5f862..efef3a753 100644 --- a/crates/uv/tests/help.rs +++ b/crates/uv/tests/help.rs @@ -186,6 +186,7 @@ fn help_subcommand() { list List the available Python installations install Download and install Python versions find Search for a Python installation + pin Pin to a specific Python version dir Show the uv Python installation directory uninstall Uninstall Python versions @@ -406,6 +407,7 @@ fn help_flag_subcommand() { list List the available Python installations install Download and install Python versions find Search for a Python installation + pin Pin to a specific Python version dir Show the uv Python installation directory uninstall Uninstall Python versions @@ -543,6 +545,7 @@ fn help_unknown_subsubcommand() { list install find + pin dir uninstall "###); diff --git a/crates/uv/tests/python_pin.rs b/crates/uv/tests/python_pin.rs new file mode 100644 index 000000000..89bf1afcc --- /dev/null +++ b/crates/uv/tests/python_pin.rs @@ -0,0 +1,450 @@ +#![cfg(all(feature = "python", feature = "pypi"))] + +use common::{uv_snapshot, TestContext}; +use insta::assert_snapshot; +use uv_python::{ + platform::{Arch, Os}, + PYTHON_VERSION_FILENAME, +}; + +mod common; + +#[test] +fn python_pin() { + let context: TestContext = TestContext::new_with_versions(&["3.11", "3.12"]); + + // Without arguments, we attempt to read the current pin (which does not exist yet) + uv_snapshot!(context.filters(), context.python_pin(), @r###" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + error: No pinned Python version found. + "###); + + // Given an argument, we pin to that version + uv_snapshot!(context.filters(), context.python_pin().arg("any"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + Pinned to `any` + + ----- stderr ----- + "###); + + let python_version = + fs_err::read_to_string(context.temp_dir.join(PYTHON_VERSION_FILENAME)).unwrap(); + assert_snapshot!(python_version, @r#"any"#); + + // Without arguments, we read the current pin + uv_snapshot!(context.filters(), context.python_pin(), @r###" + success: true + exit_code: 0 + ----- stdout ----- + any + + ----- stderr ----- + "###); + + // We should not mutate the file + let python_version = + fs_err::read_to_string(context.temp_dir.join(PYTHON_VERSION_FILENAME)).unwrap(); + assert_snapshot!(python_version, @r#"any"#); + + // Request Python 3.12 + uv_snapshot!(context.filters(), context.python_pin().arg("3.12"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + Replaced existing pin with `3.12` + + ----- stderr ----- + "###); + + let python_version = + fs_err::read_to_string(context.temp_dir.join(PYTHON_VERSION_FILENAME)).unwrap(); + assert_snapshot!(python_version, @r###" + 3.12 + "###); + + // Request Python 3.11 + uv_snapshot!(context.filters(), context.python_pin().arg("3.11"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + Replaced existing pin with `3.11` + + ----- stderr ----- + "###); + + let python_version = + fs_err::read_to_string(context.temp_dir.join(PYTHON_VERSION_FILENAME)).unwrap(); + assert_snapshot!(python_version, @r###" + 3.11 + "###); + + // Request CPython + uv_snapshot!(context.filters(), context.python_pin().arg("cpython"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + Replaced existing pin with `cpython` + + ----- stderr ----- + "###); + + let python_version = + fs_err::read_to_string(context.temp_dir.join(PYTHON_VERSION_FILENAME)).unwrap(); + assert_snapshot!(python_version, @r###" + cpython + "###); + + // Request CPython 3.12 + uv_snapshot!(context.filters(), context.python_pin().arg("cpython@3.12"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + Replaced existing pin with `cpython@3.12` + + ----- stderr ----- + "###); + + let python_version = + fs_err::read_to_string(context.temp_dir.join(PYTHON_VERSION_FILENAME)).unwrap(); + assert_snapshot!(python_version, @r###" + cpython@3.12 + "###); + + // Request CPython 3.12 via non-canonical syntax + uv_snapshot!(context.filters(), context.python_pin().arg("cp3.12"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + Replaced existing pin with `cpython@3.12` + + ----- stderr ----- + "###); + + let python_version = + fs_err::read_to_string(context.temp_dir.join(PYTHON_VERSION_FILENAME)).unwrap(); + assert_snapshot!(python_version, @r###" + cpython@3.12 + "###); + + // Request CPython 3.12 via partial key syntax + uv_snapshot!(context.filters(), context.python_pin().arg("cpython-3.12"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + Replaced existing pin with `cpython-3.12-any-any-any` + + ----- stderr ----- + "###); + + let python_version = + fs_err::read_to_string(context.temp_dir.join(PYTHON_VERSION_FILENAME)).unwrap(); + assert_snapshot!(python_version, @r###" + cpython-3.12-any-any-any + "###); + + // Request a specific path + uv_snapshot!(context.filters(), context.python_pin().arg(&context.python_versions.first().unwrap().1), @r###" + success: true + exit_code: 0 + ----- stdout ----- + Replaced existing pin with `[PYTHON-3.11]` + + ----- stderr ----- + "###); + + let python_version = + fs_err::read_to_string(context.temp_dir.join(PYTHON_VERSION_FILENAME)).unwrap(); + insta::with_settings!({ + filters => context.filters(), + }, { + assert_snapshot!(python_version, @r###" + [PYTHON-3.11] + "###); + }); + + // Request an implementation that is not installed + // (skip on Windows because the snapshot is different and the behavior is not platform dependent) + #[cfg(unix)] + { + uv_snapshot!(context.filters(), context.python_pin().arg("pypy"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + Replaced existing pin with `pypy` + + ----- stderr ----- + warning: No interpreter found for PyPy in system path + "###); + + let python_version = + fs_err::read_to_string(context.temp_dir.join(PYTHON_VERSION_FILENAME)).unwrap(); + assert_snapshot!(python_version, @r###" + pypy + "###); + } + + // Request a version that is not installed + // (skip on Windows because the snapshot is different and the behavior is not platform dependent) + #[cfg(unix)] + { + uv_snapshot!(context.filters(), context.python_pin().arg("3.7"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + Replaced existing pin with `3.7` + + ----- stderr ----- + warning: No interpreter found for Python 3.7 in system path + "###); + + let python_version = + fs_err::read_to_string(context.temp_dir.join(PYTHON_VERSION_FILENAME)).unwrap(); + assert_snapshot!(python_version, @r###" + 3.7 + "###); + } +} + +/// We do not need a Python interpreter to pin without `--resolved` +/// (skip on Windows because the snapshot is different and the behavior is not platform dependent) +#[cfg(unix)] +#[test] +fn python_pin_no_python() { + let context: TestContext = TestContext::new_with_versions(&[]); + + uv_snapshot!(context.filters(), context.python_pin().arg("3.12"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + Pinned to `3.12` + + ----- stderr ----- + warning: No interpreter found for Python 3.12 in system path + "###); +} + +/// We do need a Python interpreter for `--resolved` pins +#[test] +fn python_pin_resolve_no_python() { + let context: TestContext = TestContext::new_with_versions(&[]); + + if cfg!(windows) { + uv_snapshot!(context.filters(), context.python_pin().arg("--resolved").arg("3.12"), @r###" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + error: No interpreter found for Python 3.12 in system path or `py` launcher + "###); + } else { + uv_snapshot!(context.filters(), context.python_pin().arg("--resolved").arg("3.12"), @r###" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + error: No interpreter found for Python 3.12 in system path + "###); + } +} + +#[test] +fn python_pin_resolve() { + let context: TestContext = TestContext::new_with_versions(&["3.11", "3.12"]); + + // We pin the first interpreter on the path + uv_snapshot!(context.filters(), context.python_pin().arg("--resolved").arg("any"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + Pinned to `[PYTHON-3.11]` + + ----- stderr ----- + "###); + + let python_version = + fs_err::read_to_string(context.temp_dir.join(PYTHON_VERSION_FILENAME)).unwrap(); + insta::with_settings!({ + filters => context.filters(), + }, { + assert_snapshot!(python_version, @r###" + [PYTHON-3.11] + "###); + }); + + // Request Python 3.12 + uv_snapshot!(context.filters(), context.python_pin().arg("--resolved").arg("3.12"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + Replaced existing pin with `[PYTHON-3.12]` + + ----- stderr ----- + "###); + + let python_version = + fs_err::read_to_string(context.temp_dir.join(PYTHON_VERSION_FILENAME)).unwrap(); + insta::with_settings!({ + filters => context.filters(), + }, { + assert_snapshot!(python_version, @r###" + [PYTHON-3.12] + "###); + }); + + // Request Python 3.11 + uv_snapshot!(context.filters(), context.python_pin().arg("--resolved").arg("3.11"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + Replaced existing pin with `[PYTHON-3.11]` + + ----- stderr ----- + "###); + + let python_version = + fs_err::read_to_string(context.temp_dir.join(PYTHON_VERSION_FILENAME)).unwrap(); + insta::with_settings!({ + filters => context.filters(), + }, { + assert_snapshot!(python_version, @r###" + [PYTHON-3.11] + "###); + }); + + // Request CPython + uv_snapshot!(context.filters(), context.python_pin().arg("--resolved").arg("cpython"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + Replaced existing pin with `[PYTHON-3.11]` + + ----- stderr ----- + "###); + + let python_version = + fs_err::read_to_string(context.temp_dir.join(PYTHON_VERSION_FILENAME)).unwrap(); + insta::with_settings!({ + filters => context.filters(), + }, { + assert_snapshot!(python_version, @r###" + [PYTHON-3.11] + "###); + }); + + // Request CPython 3.12 + uv_snapshot!(context.filters(), context.python_pin().arg("--resolved").arg("cpython@3.12"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + Replaced existing pin with `[PYTHON-3.12]` + + ----- stderr ----- + "###); + + let python_version = + fs_err::read_to_string(context.temp_dir.join(PYTHON_VERSION_FILENAME)).unwrap(); + insta::with_settings!({ + filters => context.filters(), + }, { + assert_snapshot!(python_version, @r###" + [PYTHON-3.12] + "###); + }); + + // Request CPython 3.12 via partial key syntax + uv_snapshot!(context.filters(), context.python_pin().arg("--resolved").arg("cpython-3.12"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + Replaced existing pin with `[PYTHON-3.12]` + + ----- stderr ----- + "###); + + let python_version = + fs_err::read_to_string(context.temp_dir.join(PYTHON_VERSION_FILENAME)).unwrap(); + insta::with_settings!({ + filters => context.filters(), + }, { + assert_snapshot!(python_version, @r###" + [PYTHON-3.12] + "###); + }); + + // Request CPython 3.12 for the current platform + let os = Os::from_env(); + let arch = Arch::from_env(); + + uv_snapshot!(context.filters(), context.python_pin().arg("--resolved") + .arg(format!("cpython-3.12-{os}-{arch}")) + , @r###" + success: true + exit_code: 0 + ----- stdout ----- + Replaced existing pin with `[PYTHON-3.12]` + + ----- stderr ----- + "###); + + let python_version = + fs_err::read_to_string(context.temp_dir.join(PYTHON_VERSION_FILENAME)).unwrap(); + insta::with_settings!({ + filters => context.filters(), + }, { + assert_snapshot!(python_version, @r###" + [PYTHON-3.12] + "###); + }); + + // Request an implementation that is not installed + // (skip on Windows because the snapshot is different and the behavior is not platform dependent) + #[cfg(unix)] + uv_snapshot!(context.filters(), context.python_pin().arg("--resolved").arg("pypy"), @r###" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + error: No interpreter found for PyPy in system path + "###); + + let python_version = + fs_err::read_to_string(context.temp_dir.join(PYTHON_VERSION_FILENAME)).unwrap(); + insta::with_settings!({ + filters => context.filters(), + }, { + assert_snapshot!(python_version, @r###" + [PYTHON-3.12] + "###); + }); + + // Request a version that is not installed + // (skip on Windows because the snapshot is different and the behavior is not platform dependent) + #[cfg(unix)] + uv_snapshot!(context.filters(), context.python_pin().arg("--resolved").arg("3.7"), @r###" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + error: No interpreter found for Python 3.7 in system path + "###); + + let python_version = + fs_err::read_to_string(context.temp_dir.join(PYTHON_VERSION_FILENAME)).unwrap(); + insta::with_settings!({ + filters => context.filters(), + }, { + assert_snapshot!(python_version, @r###" + [PYTHON-3.12] + "###); + }); +}