Add uv python pin (#4950)

Adds a `uv python pin` command to write to a `.python-version` file.

We support all of our Python version request formats. We also support a
`--resolved` flag to pin to a specific interpreter instead of the
provided version. We canonicalize the request with #4949, it's not just
printed verbatim. We always attempt to find the interpreter so we can
warn if it's not available. With `--resolved`, if we can't find the
interpreter we fail. If no arguments are provided, we'll attempt to
display the current pin.

In the future:

- We should confirm that this satisfies the `Requires-Python` metadata
if a `pyproject.toml` is present
- We should support writing to a `uv.python-version` field if
`pyproject.toml` or `uv.toml` are present
- We should support finding and updating the "nearest" Python version
file (looking in ancestors)
- We should support finding version files in workspaces
- We should support some sort of global pin
This commit is contained in:
Zanie Blue 2024-07-10 12:52:24 -04:00 committed by GitHub
parent 7925d255f7
commit e0fae8e6f4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 622 additions and 3 deletions

View file

@ -2169,6 +2169,9 @@ pub enum PythonCommand {
/// Search for a Python installation. /// Search for a Python installation.
Find(PythonFindArgs), Find(PythonFindArgs),
/// Pin to a specific Python version.
Pin(PythonPinArgs),
/// Show the uv Python installation directory. /// Show the uv Python installation directory.
Dir, Dir,
@ -2226,6 +2229,22 @@ pub struct PythonFindArgs {
pub request: Option<String>, pub request: Option<String>,
} }
#[derive(Args)]
#[allow(clippy::struct_excessive_bools)]
pub struct PythonPinArgs {
/// The Python version.
pub request: Option<String>,
/// 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)] #[derive(Args)]
#[allow(clippy::struct_excessive_bools)] #[allow(clippy::struct_excessive_bools)]
pub struct IndexArgs { pub struct IndexArgs {

View file

@ -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::find::find as python_find;
pub(crate) use python::install::install as python_install; pub(crate) use python::install::install as python_install;
pub(crate) use python::list::list as python_list; 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; pub(crate) use python::uninstall::uninstall as python_uninstall;
#[cfg(feature = "self-update")] #[cfg(feature = "self-update")]
pub(crate) use self_update::self_update; pub(crate) use self_update::self_update;

View file

@ -2,4 +2,5 @@ pub(crate) mod dir;
pub(crate) mod find; pub(crate) mod find;
pub(crate) mod install; pub(crate) mod install;
pub(crate) mod list; pub(crate) mod list;
pub(crate) mod pin;
pub(crate) mod uninstall; pub(crate) mod uninstall;

View file

@ -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<String>,
resolved: bool,
python_preference: PythonPreference,
preview: PreviewMode,
cache: &Cache,
printer: Printer,
) -> Result<ExitStatus> {
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)
}

View file

@ -809,6 +809,25 @@ async fn run() -> Result<ExitStatus> {
) )
.await .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 { Commands::Python(PythonNamespace {
command: PythonCommand::Dir, command: PythonCommand::Dir,
}) => { }) => {

View file

@ -14,8 +14,8 @@ use uv_cli::{
AddArgs, ColorChoice, Commands, ExternalCommand, GlobalArgs, ListFormat, LockArgs, Maybe, AddArgs, ColorChoice, Commands, ExternalCommand, GlobalArgs, ListFormat, LockArgs, Maybe,
PipCheckArgs, PipCompileArgs, PipFreezeArgs, PipInstallArgs, PipListArgs, PipShowArgs, PipCheckArgs, PipCompileArgs, PipFreezeArgs, PipInstallArgs, PipListArgs, PipShowArgs,
PipSyncArgs, PipTreeArgs, PipUninstallArgs, PythonFindArgs, PythonInstallArgs, PythonListArgs, PipSyncArgs, PipTreeArgs, PipUninstallArgs, PythonFindArgs, PythonInstallArgs, PythonListArgs,
PythonUninstallArgs, RemoveArgs, RunArgs, SyncArgs, ToolInstallArgs, ToolListArgs, ToolRunArgs, PythonPinArgs, PythonUninstallArgs, RemoveArgs, RunArgs, SyncArgs, ToolInstallArgs,
ToolUninstallArgs, TreeArgs, VenvArgs, ToolListArgs, ToolRunArgs, ToolUninstallArgs, TreeArgs, VenvArgs,
}; };
use uv_client::Connectivity; use uv_client::Connectivity;
use uv_configuration::{ 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<String>,
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<FilesystemOptions>) -> 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. /// The resolved settings to use for a `sync` invocation.
#[allow(clippy::struct_excessive_bools, dead_code)] #[allow(clippy::struct_excessive_bools, dead_code)]
#[derive(Debug, Clone)] #[derive(Debug, Clone)]

View file

@ -233,7 +233,12 @@ impl TestContext {
filters.extend( filters.extend(
Self::path_patterns(&python_dir.join(version.to_string())) Self::path_patterns(&python_dir.join(version.to_string()))
.into_iter() .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 // Add Python patch version filtering unless explicitly requested to ensure
@ -429,6 +434,19 @@ impl TestContext {
command 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. /// Create a `uv python dir` command with options shared across scenarios.
pub fn python_dir(&self) -> Command { pub fn python_dir(&self) -> Command {
let mut command = Command::new(get_bin()); let mut command = Command::new(get_bin());

View file

@ -186,6 +186,7 @@ fn help_subcommand() {
list List the available Python installations list List the available Python installations
install Download and install Python versions install Download and install Python versions
find Search for a Python installation find Search for a Python installation
pin Pin to a specific Python version
dir Show the uv Python installation directory dir Show the uv Python installation directory
uninstall Uninstall Python versions uninstall Uninstall Python versions
@ -406,6 +407,7 @@ fn help_flag_subcommand() {
list List the available Python installations list List the available Python installations
install Download and install Python versions install Download and install Python versions
find Search for a Python installation find Search for a Python installation
pin Pin to a specific Python version
dir Show the uv Python installation directory dir Show the uv Python installation directory
uninstall Uninstall Python versions uninstall Uninstall Python versions
@ -543,6 +545,7 @@ fn help_unknown_subsubcommand() {
list list
install install
find find
pin
dir dir
uninstall uninstall
"###); "###);

View file

@ -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]
"###);
});
}