Install versioned Python executables into the bin directory during uv python install (#8458)
Some checks are pending
CI / check system | python on macos x86_64 (push) Blocked by required conditions
CI / cargo dev generate-all (push) Blocked by required conditions
CI / cargo shear (push) Waiting to run
CI / Determine changes (push) Waiting to run
CI / lint (push) Waiting to run
CI / cargo clippy | ubuntu (push) Blocked by required conditions
CI / cargo clippy | windows (push) Blocked by required conditions
CI / cargo test | ubuntu (push) Blocked by required conditions
CI / cargo test | macos (push) Blocked by required conditions
CI / cargo test | windows (push) Blocked by required conditions
CI / check windows trampoline | aarch64 (push) Blocked by required conditions
CI / integration test | pypy on ubuntu (push) Blocked by required conditions
CI / check windows trampoline | i686 (push) Blocked by required conditions
CI / check windows trampoline | x86_64 (push) Blocked by required conditions
CI / test windows trampoline | i686 (push) Blocked by required conditions
CI / test windows trampoline | x86_64 (push) Blocked by required conditions
CI / typos (push) Waiting to run
CI / mkdocs (push) Waiting to run
CI / build binary | linux (push) Blocked by required conditions
CI / build binary | macos aarch64 (push) Blocked by required conditions
CI / build binary | macos x86_64 (push) Blocked by required conditions
CI / build binary | windows (push) Blocked by required conditions
CI / build binary | freebsd (push) Blocked by required conditions
CI / integration test | pypy on windows (push) Blocked by required conditions
CI / ecosystem test | prefecthq/prefect (push) Blocked by required conditions
CI / ecosystem test | pallets/flask (push) Blocked by required conditions
CI / integration test | conda on ubuntu (push) Blocked by required conditions
CI / integration test | free-threaded on linux (push) Blocked by required conditions
CI / integration test | free-threaded on windows (push) Blocked by required conditions
CI / integration test | graalpy on ubuntu (push) Blocked by required conditions
CI / integration test | graalpy on windows (push) Blocked by required conditions
CI / integration test | github actions (push) Blocked by required conditions
CI / integration test | determine publish changes (push) Blocked by required conditions
CI / integration test | uv publish (push) Blocked by required conditions
CI / check cache | ubuntu (push) Blocked by required conditions
CI / check cache | macos aarch64 (push) Blocked by required conditions
CI / check system | python on debian (push) Blocked by required conditions
CI / check system | python on fedora (push) Blocked by required conditions
CI / check system | python on ubuntu (push) Blocked by required conditions
CI / check system | python on opensuse (push) Blocked by required conditions
CI / check system | homebrew python on macos aarch64 (push) Blocked by required conditions
CI / check system | python on rocky linux 8 (push) Blocked by required conditions
CI / check system | python on rocky linux 9 (push) Blocked by required conditions
CI / check system | pypy on ubuntu (push) Blocked by required conditions
CI / check system | pyston (push) Blocked by required conditions
CI / check system | alpine (push) Blocked by required conditions
CI / check system | python on macos aarch64 (push) Blocked by required conditions
CI / check system | python3.10 on windows (push) Blocked by required conditions
CI / check system | python3.10 on windows x86 (push) Blocked by required conditions
CI / check system | python3.13 on windows (push) Blocked by required conditions
CI / check system | python3.12 via chocolatey (push) Blocked by required conditions
CI / check system | python3.9 via pyenv (push) Blocked by required conditions
CI / check system | python3.13 (push) Blocked by required conditions
CI / check system | conda3.11 on linux (push) Blocked by required conditions
CI / check system | conda3.8 on linux (push) Blocked by required conditions
CI / check system | conda3.11 on macos (push) Blocked by required conditions
CI / check system | conda3.8 on macos (push) Blocked by required conditions
CI / check system | conda3.11 on windows (push) Blocked by required conditions
CI / check system | conda3.8 on windows (push) Blocked by required conditions
CI / check system | amazonlinux (push) Blocked by required conditions
CI / check system | embedded python3.10 on windows (push) Blocked by required conditions
CI / benchmarks (push) Blocked by required conditions

Updates `uv python install` to link `python3.x` in the executable
directory (i.e., `~/.local/bin`) to the the managed interpreter path.

Includes

- #8569 
- #8571 

Remaining work

- #8663 
- #8650 
- Add an opt-out setting and flag
- Update documentation
This commit is contained in:
Zanie Blue 2024-10-30 09:13:20 -05:00 committed by GitHub
parent 94fc35edd9
commit 4dd36b799f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 597 additions and 89 deletions

View file

@ -702,7 +702,7 @@ jobs:
- name: "Install free-threaded Python via uv" - name: "Install free-threaded Python via uv"
run: | run: |
./uv python install 3.13t ./uv python install -v 3.13t
./uv venv -p 3.13t --python-preference only-managed ./uv venv -p 3.13t --python-preference only-managed
- name: "Check version" - name: "Check version"
@ -774,7 +774,7 @@ jobs:
run: chmod +x ./uv run: chmod +x ./uv
- name: "Install PyPy" - name: "Install PyPy"
run: ./uv python install pypy3.9 run: ./uv python install -v pypy3.9
- name: "Create a virtual environment" - name: "Create a virtual environment"
run: | run: |

2
Cargo.lock generated
View file

@ -4208,6 +4208,7 @@ dependencies = [
"regex", "regex",
"reqwest", "reqwest",
"rustc-hash", "rustc-hash",
"same-file",
"serde", "serde",
"serde_json", "serde_json",
"similar", "similar",
@ -5060,6 +5061,7 @@ dependencies = [
"uv-cache-info", "uv-cache-info",
"uv-cache-key", "uv-cache-key",
"uv-client", "uv-client",
"uv-dirs",
"uv-distribution-filename", "uv-distribution-filename",
"uv-extract", "uv-extract",
"uv-fs", "uv-fs",

View file

@ -3803,14 +3803,15 @@ pub enum PythonCommand {
/// ///
/// Multiple Python versions may be requested. /// Multiple Python versions may be requested.
/// ///
/// Supports CPython and PyPy. /// Supports CPython and PyPy. CPython distributions are downloaded from the
/// `python-build-standalone` project. PyPy distributions are downloaded from `python.org`.
/// ///
/// CPython distributions are downloaded from the `python-build-standalone` project. /// Python versions are installed into the uv Python directory, which can be retrieved with `uv
/// python dir`.
/// ///
/// Python versions are installed into the uv Python directory, which can be /// A `python` executable is not made globally available, managed Python versions are only used
/// retrieved with `uv python dir`. A `python` executable is not made /// in uv commands or in active virtual environments. There is experimental support for
/// globally available, managed Python versions are only used in uv /// adding Python executables to the `PATH` — use the `--preview` flag to enable this behavior.
/// commands or in active virtual environments.
/// ///
/// See `uv help python` to view supported request formats. /// See `uv help python` to view supported request formats.
Install(PythonInstallArgs), Install(PythonInstallArgs),
@ -3838,7 +3839,9 @@ pub enum PythonCommand {
/// `%APPDATA%\uv\data\python` on Windows. /// `%APPDATA%\uv\data\python` on Windows.
/// ///
/// The Python installation directory may be overridden with `$UV_PYTHON_INSTALL_DIR`. /// The Python installation directory may be overridden with `$UV_PYTHON_INSTALL_DIR`.
Dir, ///
/// To instead view the directory uv installs Python executables into, use the `--bin` flag.
Dir(PythonDirArgs),
/// Uninstall Python versions. /// Uninstall Python versions.
Uninstall(PythonUninstallArgs), Uninstall(PythonUninstallArgs),
@ -3866,6 +3869,27 @@ pub struct PythonListArgs {
pub only_installed: bool, pub only_installed: bool,
} }
#[derive(Args)]
#[allow(clippy::struct_excessive_bools)]
pub struct PythonDirArgs {
/// Show the directory into which `uv python` will install Python executables.
///
/// Note this directory is only used when installing with preview mode enabled.
///
/// By default, `uv python dir` shows the directory into which the Python distributions
/// themselves are installed, rather than the directory containing the linked executables.
///
/// The Python executable directory is determined according to the XDG standard and is derived
/// from the following environment variables, in order of preference:
///
/// - `$UV_PYTHON_BIN_DIR`
/// - `$XDG_BIN_HOME`
/// - `$XDG_DATA_HOME/../bin`
/// - `$HOME/.local/bin`
#[arg(long, verbatim_doc_comment)]
pub bin: bool,
}
#[derive(Args)] #[derive(Args)]
#[allow(clippy::struct_excessive_bools)] #[allow(clippy::struct_excessive_bools)]
pub struct PythonInstallArgs { pub struct PythonInstallArgs {

View file

@ -20,6 +20,7 @@ uv-cache = { workspace = true }
uv-cache-info = { workspace = true } uv-cache-info = { workspace = true }
uv-cache-key = { workspace = true } uv-cache-key = { workspace = true }
uv-client = { workspace = true } uv-client = { workspace = true }
uv-dirs = { workspace = true }
uv-distribution-filename = { workspace = true } uv-distribution-filename = { workspace = true }
uv-extract = { workspace = true } uv-extract = { workspace = true }
uv-fs = { workspace = true } uv-fs = { workspace = true }

View file

@ -1236,6 +1236,16 @@ impl PythonVariant {
PythonVariant::Freethreaded => interpreter.gil_disabled(), PythonVariant::Freethreaded => interpreter.gil_disabled(),
} }
} }
/// Return the lib or executable suffix for the variant, e.g., `t` for `python3.13t`.
///
/// Returns an empty string for the default Python variant.
pub fn suffix(self) -> &'static str {
match self {
Self::Default => "",
Self::Freethreaded => "t",
}
}
} }
impl PythonRequest { impl PythonRequest {
/// Create a request from a string. /// Create a request from a string.
@ -1635,12 +1645,7 @@ impl std::fmt::Display for ExecutableName {
if let Some(prerelease) = &self.prerelease { if let Some(prerelease) = &self.prerelease {
write!(f, "{prerelease}")?; write!(f, "{prerelease}")?;
} }
match self.variant { f.write_str(self.variant.suffix())?;
PythonVariant::Default => {}
PythonVariant::Freethreaded => {
f.write_str("t")?;
}
};
f.write_str(std::env::consts::EXE_SUFFIX)?; f.write_str(std::env::consts::EXE_SUFFIX)?;
Ok(()) Ok(())
} }

View file

@ -305,6 +305,17 @@ impl PythonInstallationKey {
pub fn libc(&self) -> &Libc { pub fn libc(&self) -> &Libc {
&self.libc &self.libc
} }
/// Return a canonical name for a versioned executable.
pub fn versioned_executable_name(&self) -> String {
format!(
"python{maj}.{min}{var}{exe}",
maj = self.major,
min = self.minor,
var = self.variant.suffix(),
exe = std::env::consts::EXE_SUFFIX
)
}
} }
impl fmt::Display for PythonInstallationKey { impl fmt::Display for PythonInstallationKey {

View file

@ -53,14 +53,31 @@ pub enum Error {
#[source] #[source]
err: io::Error, err: io::Error,
}, },
#[error("Failed to create Python executable link at {} from {}", to.user_display(), from.user_display())]
LinkExecutable {
from: PathBuf,
to: PathBuf,
#[source]
err: io::Error,
},
#[error("Failed to create directory for Python executable link at {}", to.user_display())]
ExecutableDirectory {
to: PathBuf,
#[source]
err: io::Error,
},
#[error("Failed to read Python installation directory: {0}", dir.user_display())] #[error("Failed to read Python installation directory: {0}", dir.user_display())]
ReadError { ReadError {
dir: PathBuf, dir: PathBuf,
#[source] #[source]
err: io::Error, err: io::Error,
}, },
#[error("Failed to find a directory to install executables into")]
NoExecutableDirectory,
#[error("Failed to read managed Python directory name: {0}")] #[error("Failed to read managed Python directory name: {0}")]
NameError(String), NameError(String),
#[error("Failed to construct absolute path to managed Python directory: {}", _0.user_display())]
AbsolutePath(PathBuf, #[source] std::io::Error),
#[error(transparent)] #[error(transparent)]
NameParseError(#[from] installation::PythonInstallationKeyError), NameParseError(#[from] installation::PythonInstallationKeyError),
#[error(transparent)] #[error(transparent)]
@ -267,18 +284,78 @@ impl ManagedPythonInstallation {
.ok_or(Error::NameError("not a valid string".to_string()))?, .ok_or(Error::NameError("not a valid string".to_string()))?,
)?; )?;
let path = std::path::absolute(&path).map_err(|err| Error::AbsolutePath(path, err))?;
Ok(Self { path, key }) Ok(Self { path, key })
} }
/// The path to this toolchain's Python executable. /// The path to this managed installation's Python executable.
///
/// If the installation has multiple execututables i.e., `python`, `python3`, etc., this will
/// return the _canonical_ executable name which the other names link to. On Unix, this is
/// `python{major}.{minor}{variant}` and on Windows, this is `python{exe}`.
pub fn executable(&self) -> PathBuf { pub fn executable(&self) -> PathBuf {
if cfg!(windows) { let implementation = match self.implementation() {
self.python_dir().join("python.exe") ImplementationName::CPython => "python",
ImplementationName::PyPy => "pypy",
ImplementationName::GraalPy => {
unreachable!("Managed installations of GraalPy are not supported")
}
};
let version = match self.implementation() {
ImplementationName::CPython => {
if cfg!(unix) {
format!("{}.{}", self.key.major, self.key.minor)
} else {
String::new()
}
}
// PyPy uses a full version number, even on Windows.
ImplementationName::PyPy => format!("{}.{}", self.key.major, self.key.minor),
ImplementationName::GraalPy => {
unreachable!("Managed installations of GraalPy are not supported")
}
};
// On Windows, the executable is just `python.exe` even for alternative variants
let variant = if cfg!(unix) {
self.key.variant.suffix()
} else {
""
};
let name = format!(
"{implementation}{version}{variant}{exe}",
exe = std::env::consts::EXE_SUFFIX
);
let executable = if cfg!(windows) {
self.python_dir().join(name)
} else if cfg!(unix) { } else if cfg!(unix) {
self.python_dir().join("bin").join("python3") self.python_dir().join("bin").join(name)
} else { } else {
unimplemented!("Only Windows and Unix systems are supported.") unimplemented!("Only Windows and Unix systems are supported.")
};
// Workaround for python-build-standalone v20241016 which is missing the standard
// `python.exe` executable in free-threaded distributions on Windows.
//
// See https://github.com/astral-sh/uv/issues/8298
if cfg!(windows)
&& matches!(self.key.variant, PythonVariant::Freethreaded)
&& !executable.exists()
{
// This is the alternative executable name for the freethreaded variant
return self.python_dir().join(format!(
"python{}.{}t{}",
self.key.major,
self.key.minor,
std::env::consts::EXE_SUFFIX
));
} }
executable
} }
fn python_dir(&self) -> PathBuf { fn python_dir(&self) -> PathBuf {
@ -336,39 +413,38 @@ impl ManagedPythonInstallation {
pub fn ensure_canonical_executables(&self) -> Result<(), Error> { pub fn ensure_canonical_executables(&self) -> Result<(), Error> {
let python = self.executable(); let python = self.executable();
// Workaround for python-build-standalone v20241016 which is missing the standard let canonical_names = &["python"];
// `python.exe` executable in free-threaded distributions on Windows.
// for name in canonical_names {
// See https://github.com/astral-sh/uv/issues/8298 let executable =
if !python.try_exists()? { python.with_file_name(format!("{name}{exe}", exe = std::env::consts::EXE_SUFFIX));
match self.key.variant {
PythonVariant::Default => return Err(Error::MissingExecutable(python.clone())), // Do not attempt to perform same-file copies — this is fine on Unix but fails on
PythonVariant::Freethreaded => { // Windows with a permission error instead of 'already exists'
// This is the alternative executable name for the freethreaded variant if executable == python {
let python_in_dist = self.python_dir().join(format!( continue;
"python{}.{}t{}",
self.key.major,
self.key.minor,
std::env::consts::EXE_SUFFIX
));
debug!(
"Creating link {} -> {}",
python.user_display(),
python_in_dist.user_display()
);
uv_fs::symlink_copy_fallback_file(&python_in_dist, &python).map_err(|err| {
if err.kind() == io::ErrorKind::NotFound {
Error::MissingExecutable(python_in_dist.clone())
} else {
Error::CanonicalizeExecutable {
from: python_in_dist,
to: python,
err,
}
}
})?;
}
} }
match uv_fs::symlink_copy_fallback_file(&python, &executable) {
Ok(()) => {
debug!(
"Created link {} -> {}",
executable.user_display(),
python.user_display(),
);
}
Err(err) if err.kind() == io::ErrorKind::NotFound => {
return Err(Error::MissingExecutable(python.clone()))
}
Err(err) if err.kind() == io::ErrorKind::AlreadyExists => {}
Err(err) => {
return Err(Error::CanonicalizeExecutable {
from: executable,
to: python,
err,
})
}
};
} }
Ok(()) Ok(())
@ -381,10 +457,7 @@ impl ManagedPythonInstallation {
let stdlib = if matches!(self.key.os, Os(target_lexicon::OperatingSystem::Windows)) { let stdlib = if matches!(self.key.os, Os(target_lexicon::OperatingSystem::Windows)) {
self.python_dir().join("Lib") self.python_dir().join("Lib")
} else { } else {
let lib_suffix = match self.key.variant { let lib_suffix = self.key.variant.suffix();
PythonVariant::Default => "",
PythonVariant::Freethreaded => "t",
};
let python = if matches!( let python = if matches!(
self.key.implementation, self.key.implementation,
LenientImplementationName::Known(ImplementationName::PyPy) LenientImplementationName::Known(ImplementationName::PyPy)
@ -401,6 +474,31 @@ impl ManagedPythonInstallation {
Ok(()) Ok(())
} }
/// Create a link to the Python executable in the given `bin` directory.
pub fn create_bin_link(&self, bin: &Path) -> Result<PathBuf, Error> {
let python = self.executable();
fs_err::create_dir_all(bin).map_err(|err| Error::ExecutableDirectory {
to: bin.to_path_buf(),
err,
})?;
// TODO(zanieb): Add support for a "default" which
let python_in_bin = bin.join(self.key.versioned_executable_name());
match uv_fs::symlink_copy_fallback_file(&python, &python_in_bin) {
Ok(()) => Ok(python_in_bin),
Err(err) if err.kind() == io::ErrorKind::NotFound => {
Err(Error::MissingExecutable(python.clone()))
}
Err(err) => Err(Error::LinkExecutable {
from: python,
to: python_in_bin,
err,
}),
}
}
} }
/// Generate a platform portion of a key from the environment. /// Generate a platform portion of a key from the environment.
@ -423,3 +521,9 @@ impl fmt::Display for ManagedPythonInstallation {
) )
} }
} }
/// Find the directory to install Python executables into.
pub fn python_executable_dir() -> Result<PathBuf, Error> {
uv_dirs::user_executable_directory(Some(EnvVars::UV_PYTHON_BIN_DIR))
.ok_or(Error::NoExecutableDirectory)
}

View file

@ -141,6 +141,9 @@ impl EnvVars {
/// Specifies the path to the project virtual environment. /// Specifies the path to the project virtual environment.
pub const UV_PROJECT_ENVIRONMENT: &'static str = "UV_PROJECT_ENVIRONMENT"; pub const UV_PROJECT_ENVIRONMENT: &'static str = "UV_PROJECT_ENVIRONMENT";
/// Specifies the directory to place links to installed, managed Python executables.
pub const UV_PYTHON_BIN_DIR: &'static str = "UV_PYTHON_BIN_DIR";
/// Specifies the directory for storing managed Python installations. /// Specifies the directory for storing managed Python installations.
pub const UV_PYTHON_INSTALL_DIR: &'static str = "UV_PYTHON_INSTALL_DIR"; pub const UV_PYTHON_INSTALL_DIR: &'static str = "UV_PYTHON_INSTALL_DIR";

View file

@ -42,7 +42,7 @@ pub enum Error {
EntrypointRead(#[from] uv_install_wheel::Error), EntrypointRead(#[from] uv_install_wheel::Error),
#[error("Failed to find dist-info directory `{0}` in environment at {1}")] #[error("Failed to find dist-info directory `{0}` in environment at {1}")]
DistInfoMissing(String, PathBuf), DistInfoMissing(String, PathBuf),
#[error("Failed to find a directory for executables")] #[error("Failed to find a directory to install executables into")]
NoExecutableDirectory, NoExecutableDirectory,
#[error(transparent)] #[error(transparent)]
ToolName(#[from] InvalidNameError), ToolName(#[from] InvalidNameError),

View file

@ -78,6 +78,7 @@ rayon = { workspace = true }
regex = { workspace = true } regex = { workspace = true }
reqwest = { workspace = true } reqwest = { workspace = true }
rustc-hash = { workspace = true } rustc-hash = { workspace = true }
same-file = { workspace = true }
serde = { workspace = true } serde = { workspace = true }
serde_json = { workspace = true } serde_json = { workspace = true }
tempfile = { workspace = true } tempfile = { workspace = true }

View file

@ -3,15 +3,21 @@ use anyhow::Context;
use owo_colors::OwoColorize; use owo_colors::OwoColorize;
use uv_fs::Simplified; use uv_fs::Simplified;
use uv_python::managed::ManagedPythonInstallations; use uv_python::managed::{python_executable_dir, ManagedPythonInstallations};
/// Show the Python installation directory.
pub(crate) fn dir(bin: bool) -> anyhow::Result<()> {
if bin {
let bin = python_executable_dir()?;
println!("{}", bin.simplified_display().cyan());
} else {
let installed_toolchains = ManagedPythonInstallations::from_settings()
.context("Failed to initialize toolchain settings")?;
println!(
"{}",
installed_toolchains.root().simplified_display().cyan()
);
}
/// Show the toolchain directory.
pub(crate) fn dir() -> anyhow::Result<()> {
let installed_toolchains = ManagedPythonInstallations::from_settings()
.context("Failed to initialize toolchain settings")?;
println!(
"{}",
installed_toolchains.root().simplified_display().cyan()
);
Ok(()) Ok(())
} }

View file

@ -1,18 +1,27 @@
use std::collections::BTreeSet;
use std::fmt::Write;
use std::io::ErrorKind;
use std::path::Path;
use anyhow::Result; use anyhow::Result;
use futures::stream::FuturesUnordered; use futures::stream::FuturesUnordered;
use futures::StreamExt; use futures::StreamExt;
use itertools::Itertools; use itertools::Itertools;
use owo_colors::OwoColorize; use owo_colors::OwoColorize;
use rustc_hash::FxHashSet; use rustc_hash::FxHashSet;
use std::collections::BTreeSet; use same_file::is_same_file;
use std::fmt::Write;
use std::path::Path;
use tracing::debug; use tracing::debug;
use uv_client::Connectivity; use uv_client::Connectivity;
use uv_configuration::PreviewMode;
use uv_fs::Simplified;
use uv_python::downloads::{DownloadResult, ManagedPythonDownload, PythonDownloadRequest}; use uv_python::downloads::{DownloadResult, ManagedPythonDownload, PythonDownloadRequest};
use uv_python::managed::{ManagedPythonInstallation, ManagedPythonInstallations}; use uv_python::managed::{
python_executable_dir, ManagedPythonInstallation, ManagedPythonInstallations,
};
use uv_python::{PythonDownloads, PythonRequest, PythonVersionFile}; use uv_python::{PythonDownloads, PythonRequest, PythonVersionFile};
use uv_shell::Shell;
use uv_warnings::warn_user;
use crate::commands::python::{ChangeEvent, ChangeEventKind}; use crate::commands::python::{ChangeEvent, ChangeEventKind};
use crate::commands::reporters::PythonDownloadReporter; use crate::commands::reporters::PythonDownloadReporter;
@ -28,6 +37,7 @@ pub(crate) async fn install(
native_tls: bool, native_tls: bool,
connectivity: Connectivity, connectivity: Connectivity,
no_config: bool, no_config: bool,
preview: PreviewMode,
printer: Printer, printer: Printer,
) -> Result<ExitStatus> { ) -> Result<ExitStatus> {
let start = std::time::Instant::now(); let start = std::time::Instant::now();
@ -156,6 +166,12 @@ pub(crate) async fn install(
}); });
} }
let bin = if preview.is_enabled() {
Some(python_executable_dir()?)
} else {
None
};
let mut installed = FxHashSet::default(); let mut installed = FxHashSet::default();
let mut errors = vec![]; let mut errors = vec![];
while let Some((key, result)) = tasks.next().await { while let Some((key, result)) = tasks.next().await {
@ -173,9 +189,46 @@ pub(crate) async fn install(
let managed = ManagedPythonInstallation::new(path.clone())?; let managed = ManagedPythonInstallation::new(path.clone())?;
managed.ensure_externally_managed()?; managed.ensure_externally_managed()?;
managed.ensure_canonical_executables()?; managed.ensure_canonical_executables()?;
if preview.is_enabled() && cfg!(unix) {
let bin = bin
.as_ref()
.expect("We should have a bin directory with preview enabled")
.as_path();
match managed.create_bin_link(bin) {
Ok(executable) => {
debug!("Installed {} executable to {}", key, executable.display());
}
Err(uv_python::managed::Error::LinkExecutable { from, to, err })
if err.kind() == ErrorKind::AlreadyExists =>
{
// TODO(zanieb): Add `--force`
if reinstall {
fs_err::remove_file(&to)?;
let executable = managed.create_bin_link(bin)?;
debug!(
"Replaced {} executable at {}",
key,
executable.user_display()
);
} else {
if !is_same_file(&to, &from).unwrap_or_default() {
errors.push((
key,
anyhow::anyhow!(
"Executable already exists at `{}`. Use `--reinstall` to force replacement.",
to.user_display()
),
));
}
}
}
Err(err) => return Err(err.into()),
}
}
} }
Err(err) => { Err(err) => {
errors.push((key, err)); errors.push((key, anyhow::Error::new(err)));
} }
} }
} }
@ -232,16 +285,42 @@ pub(crate) async fn install(
{ {
match event.kind { match event.kind {
ChangeEventKind::Added => { ChangeEventKind::Added => {
writeln!(printer.stderr(), " {} {}", "+".green(), event.key.bold())?; writeln!(
printer.stderr(),
" {} {} ({})",
"+".green(),
event.key.bold(),
event.key.versioned_executable_name()
)?;
} }
ChangeEventKind::Removed => { ChangeEventKind::Removed => {
writeln!(printer.stderr(), " {} {}", "-".red(), event.key.bold())?; writeln!(
printer.stderr(),
" {} {} ({})",
"-".red(),
event.key.bold(),
event.key.versioned_executable_name()
)?;
} }
ChangeEventKind::Reinstalled => { ChangeEventKind::Reinstalled => {
writeln!(printer.stderr(), " {} {}", "~".yellow(), event.key.bold(),)?; writeln!(
printer.stderr(),
" {} {} ({})",
"~".yellow(),
event.key.bold(),
event.key.versioned_executable_name()
)?;
} }
} }
} }
if preview.is_enabled() && cfg!(unix) {
let bin = bin
.as_ref()
.expect("We should have a bin directory with preview enabled")
.as_path();
warn_if_not_on_path(bin);
}
} }
if !errors.is_empty() { if !errors.is_empty() {
@ -255,7 +334,7 @@ pub(crate) async fn install(
"error".red().bold(), "error".red().bold(),
key.green() key.green()
)?; )?;
for err in anyhow::Error::new(err).chain() { for err in err.chain() {
writeln!( writeln!(
printer.stderr(), printer.stderr(),
" {}: {}", " {}: {}",
@ -269,3 +348,36 @@ pub(crate) async fn install(
Ok(ExitStatus::Success) Ok(ExitStatus::Success)
} }
fn warn_if_not_on_path(bin: &Path) {
if !Shell::contains_path(bin) {
if let Some(shell) = Shell::from_env() {
if let Some(command) = shell.prepend_path(bin) {
if shell.configuration_files().is_empty() {
warn_user!(
"`{}` is not on your PATH. To use the installed Python executable, run `{}`.",
bin.simplified_display().cyan(),
command.green()
);
} else {
// TODO(zanieb): Update when we add `uv python update-shell` to match `uv tool`
warn_user!(
"`{}` is not on your PATH. To use the installed Python executable, run `{}`.",
bin.simplified_display().cyan(),
command.green(),
);
}
} else {
warn_user!(
"`{}` is not on your PATH. To use the installed Python executable, add the directory to your PATH.",
bin.simplified_display().cyan(),
);
}
} else {
warn_user!(
"`{}` is not on your PATH. To use the installed Python executable, add the directory to your PATH.",
bin.simplified_display().cyan(),
);
}
}
}

View file

@ -7,8 +7,11 @@ use futures::StreamExt;
use itertools::Itertools; use itertools::Itertools;
use owo_colors::OwoColorize; use owo_colors::OwoColorize;
use same_file::is_same_file;
use tracing::{debug, warn};
use uv_fs::Simplified;
use uv_python::downloads::PythonDownloadRequest; use uv_python::downloads::PythonDownloadRequest;
use uv_python::managed::ManagedPythonInstallations; use uv_python::managed::{python_executable_dir, ManagedPythonInstallations};
use uv_python::PythonRequest; use uv_python::PythonRequest;
use crate::commands::python::{ChangeEvent, ChangeEventKind}; use crate::commands::python::{ChangeEvent, ChangeEventKind};
@ -121,6 +124,40 @@ async fn do_uninstall(
return Ok(ExitStatus::Failure); return Ok(ExitStatus::Failure);
} }
// Collect files in a directory
let executables = python_executable_dir()?
.read_dir()?
.filter_map(|entry| match entry {
Ok(entry) => Some(entry),
Err(err) => {
warn!("Failed to read executable: {}", err);
None
}
})
.filter(|entry| entry.file_type().is_ok_and(|file_type| !file_type.is_dir()))
.map(|entry| entry.path())
// Only include files that match the expected Python executable names
// TODO(zanieb): This is a minor optimization to avoid opening more files, but we could
// leave broken links behind, i.e., if the user created them.
.filter(|path| {
matching_installations.iter().any(|installation| {
path.file_name().and_then(|name| name.to_str())
== Some(&installation.key().versioned_executable_name())
})
})
// Only include Python executables that match the installations
.filter(|path| {
matching_installations.iter().any(|installation| {
is_same_file(path, installation.executable()).unwrap_or_default()
})
})
.collect::<BTreeSet<_>>();
for executable in &executables {
fs_err::remove_file(executable)?;
debug!("Removed {}", executable.user_display());
}
let mut tasks = FuturesUnordered::new(); let mut tasks = FuturesUnordered::new();
for installation in &matching_installations { for installation in &matching_installations {
tasks.push(async { tasks.push(async {
@ -180,7 +217,13 @@ async fn do_uninstall(
{ {
match event.kind { match event.kind {
ChangeEventKind::Removed => { ChangeEventKind::Removed => {
writeln!(printer.stderr(), " {} {}", "-".red(), event.key.bold())?; writeln!(
printer.stderr(),
" {} {} ({})",
"-".red(),
event.key.bold(),
event.key.versioned_executable_name()
)?;
} }
_ => unreachable!(), _ => unreachable!(),
} }

View file

@ -1057,6 +1057,7 @@ async fn run(mut cli: Cli) -> Result<ExitStatus> {
globals.native_tls, globals.native_tls,
globals.connectivity, globals.connectivity,
cli.top_level.no_config, cli.top_level.no_config,
globals.preview,
printer, printer,
) )
.await .await
@ -1111,9 +1112,13 @@ async fn run(mut cli: Cli) -> Result<ExitStatus> {
.await .await
} }
Commands::Python(PythonNamespace { Commands::Python(PythonNamespace {
command: PythonCommand::Dir, command: PythonCommand::Dir(args),
}) => { }) => {
commands::python_dir()?; // Resolve the settings from the command-line arguments and workspace configuration.
let args = settings::PythonDirSettings::resolve(args, filesystem);
show_settings!(args);
commands::python_dir(args.bin)?;
Ok(ExitStatus::Success) Ok(ExitStatus::Success)
} }
Commands::Publish(args) => { Commands::Publish(args) => {

View file

@ -8,7 +8,7 @@ use url::Url;
use uv_cache::{CacheArgs, Refresh}; use uv_cache::{CacheArgs, Refresh};
use uv_cli::{ use uv_cli::{
options::{flag, resolver_installer_options, resolver_options}, options::{flag, resolver_installer_options, resolver_options},
AuthorFrom, BuildArgs, ExportArgs, PublishArgs, ToolUpgradeArgs, AuthorFrom, BuildArgs, ExportArgs, PublishArgs, PythonDirArgs, ToolUpgradeArgs,
}; };
use uv_cli::{ use uv_cli::{
AddArgs, ColorChoice, ExternalCommand, GlobalArgs, InitArgs, ListFormat, LockArgs, Maybe, AddArgs, ColorChoice, ExternalCommand, GlobalArgs, InitArgs, ListFormat, LockArgs, Maybe,
@ -597,6 +597,23 @@ impl PythonListSettings {
} }
} }
/// The resolved settings to use for a `python dir` invocation.
#[allow(clippy::struct_excessive_bools)]
#[derive(Debug, Clone)]
pub(crate) struct PythonDirSettings {
pub(crate) bin: bool,
}
impl PythonDirSettings {
/// Resolve the [`PythonDirSettings`] from the CLI and filesystem configuration.
#[allow(clippy::needless_pass_by_value)]
pub(crate) fn resolve(args: PythonDirArgs, _filesystem: Option<FilesystemOptions>) -> Self {
let PythonDirArgs { bin } = args;
Self { bin }
}
}
/// The resolved settings to use for a `python install` invocation. /// The resolved settings to use for a `python install` invocation.
#[allow(clippy::struct_excessive_bools)] #[allow(clippy::struct_excessive_bools)]
#[derive(Debug, Clone)] #[derive(Debug, Clone)]

View file

@ -653,11 +653,13 @@ impl TestContext {
pub fn python_install(&self) -> Command { pub fn python_install(&self) -> Command {
let mut command = self.new_command(); let mut command = self.new_command();
let managed = self.temp_dir.join("managed"); let managed = self.temp_dir.join("managed");
let bin = self.temp_dir.join("bin");
self.add_shared_args(&mut command, true); self.add_shared_args(&mut command, true);
command command
.arg("python") .arg("python")
.arg("install") .arg("install")
.env(EnvVars::UV_PYTHON_INSTALL_DIR, managed) .env(EnvVars::UV_PYTHON_INSTALL_DIR, managed)
.env(EnvVars::UV_PYTHON_BIN_DIR, bin)
.current_dir(&self.temp_dir); .current_dir(&self.temp_dir);
command command
} }
@ -666,11 +668,13 @@ impl TestContext {
pub fn python_uninstall(&self) -> Command { pub fn python_uninstall(&self) -> Command {
let mut command = self.new_command(); let mut command = self.new_command();
let managed = self.temp_dir.join("managed"); let managed = self.temp_dir.join("managed");
let bin = self.temp_dir.join("bin");
self.add_shared_args(&mut command, true); self.add_shared_args(&mut command, true);
command command
.arg("python") .arg("python")
.arg("uninstall") .arg("uninstall")
.env(EnvVars::UV_PYTHON_INSTALL_DIR, managed) .env(EnvVars::UV_PYTHON_INSTALL_DIR, managed)
.env(EnvVars::UV_PYTHON_BIN_DIR, bin)
.current_dir(&self.temp_dir); .current_dir(&self.temp_dir);
command command
} }

View file

@ -411,13 +411,15 @@ fn help_subsubcommand() {
Multiple Python versions may be requested. Multiple Python versions may be requested.
Supports CPython and PyPy. Supports CPython and PyPy. CPython distributions are downloaded from the `python-build-standalone`
project. PyPy distributions are downloaded from `python.org`.
CPython distributions are downloaded from the `python-build-standalone` project.
Python versions are installed into the uv Python directory, which can be retrieved with `uv python Python versions are installed into the uv Python directory, which can be retrieved with `uv python
dir`. A `python` executable is not made globally available, managed Python versions are only used in dir`.
uv commands or in active virtual environments.
A `python` executable is not made globally available, managed Python versions are only used in uv
commands or in active virtual environments. There is experimental support for adding Python
executables to the `PATH` use the `--preview` flag to enable this behavior.
See `uv help python` to view supported request formats. See `uv help python` to view supported request formats.

View file

@ -1,3 +1,8 @@
use std::process::Command;
use assert_fs::{assert::PathAssert, prelude::PathChild};
use predicates::prelude::predicate;
use crate::common::{uv_snapshot, TestContext}; use crate::common::{uv_snapshot, TestContext};
#[test] #[test]
@ -16,6 +21,14 @@ fn python_install() {
+ cpython-3.13.0-[PLATFORM] + cpython-3.13.0-[PLATFORM]
"###); "###);
let bin_python = context
.temp_dir
.child("bin")
.child(format!("python3.13{}", std::env::consts::EXE_SUFFIX));
// The executable should not be installed in the bin directory (requires preview)
bin_python.assert(predicate::path::missing());
// Should be a no-op when already installed // Should be a no-op when already installed
uv_snapshot!(context.filters(), context.python_install(), @r###" uv_snapshot!(context.filters(), context.python_install(), @r###"
success: true success: true
@ -39,6 +52,114 @@ fn python_install() {
Found existing installation for Python 3.13: cpython-3.13.0-[PLATFORM] Found existing installation for Python 3.13: cpython-3.13.0-[PLATFORM]
"###); "###);
// You can opt-in to a reinstall
uv_snapshot!(context.filters(), context.python_install().arg("--reinstall"), @r###"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Searching for Python installations
Found: cpython-3.13.0-[PLATFORM]
Installed Python 3.13.0 in [TIME]
~ cpython-3.13.0-[PLATFORM]
"###);
// Uninstallation requires an argument
uv_snapshot!(context.filters(), context.python_uninstall(), @r###"
success: false
exit_code: 2
----- stdout -----
----- stderr -----
error: the following required arguments were not provided:
<TARGETS>...
Usage: uv python uninstall <TARGETS>...
For more information, try '--help'.
"###);
uv_snapshot!(context.filters(), context.python_uninstall().arg("3.13"), @r###"
success: false
exit_code: 2
----- stdout -----
----- stderr -----
Searching for Python versions matching: Python 3.13
error: No such file or directory (os error 2)
"###);
}
#[test]
fn python_install_preview() {
let context: TestContext = TestContext::new_with_versions(&[]).with_filtered_python_keys();
// Install the latest version
uv_snapshot!(context.filters(), context.python_install().arg("--preview"), @r###"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Searching for Python installations
Installed Python 3.13.0 in [TIME]
+ cpython-3.13.0-[PLATFORM]
warning: `[TEMP_DIR]/bin` is not on your PATH. To use the installed Python executable, run `export PATH="[TEMP_DIR]/bin:$PATH"`.
"###);
let bin_python = context
.temp_dir
.child("bin")
.child(format!("python3.13{}", std::env::consts::EXE_SUFFIX));
// The executable should be installed in the bin directory
bin_python.assert(predicate::path::exists());
// On Unix, it should be a link
#[cfg(unix)]
bin_python.assert(predicate::path::is_symlink());
// The executable should "work"
uv_snapshot!(context.filters(), Command::new(bin_python.as_os_str())
.arg("-c").arg("import subprocess; print('hello world')"), @r###"
success: true
exit_code: 0
----- stdout -----
hello world
----- stderr -----
"###);
// Should be a no-op when already installed
uv_snapshot!(context.filters(), context.python_install().arg("--preview"), @r###"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Searching for Python installations
Found: cpython-3.13.0-[PLATFORM]
Python is already available. Use `uv python install <request>` to install a specific version.
"###);
// You can opt-in to a reinstall
uv_snapshot!(context.filters(), context.python_install().arg("--preview").arg("--reinstall"), @r###"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Searching for Python installations
Found: cpython-3.13.0-[PLATFORM]
Installed Python 3.13.0 in [TIME]
~ cpython-3.13.0-[PLATFORM]
warning: `[TEMP_DIR]/bin` is not on your PATH. To use the installed Python executable, run `export PATH="[TEMP_DIR]/bin:$PATH"`.
"###);
// The executable should still be present in the bin directory
bin_python.assert(predicate::path::exists());
// Uninstallation requires an argument // Uninstallation requires an argument
uv_snapshot!(context.filters(), context.python_uninstall(), @r###" uv_snapshot!(context.filters(), context.python_uninstall(), @r###"
success: false success: false
@ -64,6 +185,9 @@ fn python_install() {
Uninstalled Python 3.13.0 in [TIME] Uninstalled Python 3.13.0 in [TIME]
- cpython-3.13.0-[PLATFORM] - cpython-3.13.0-[PLATFORM]
"###); "###);
// The executable should be removed
bin_python.assert(predicate::path::missing());
} }
#[test] #[test]
@ -71,7 +195,7 @@ fn python_install_freethreaded() {
let context: TestContext = TestContext::new_with_versions(&[]).with_filtered_python_keys(); let context: TestContext = TestContext::new_with_versions(&[]).with_filtered_python_keys();
// Install the latest version // Install the latest version
uv_snapshot!(context.filters(), context.python_install().arg("3.13t"), @r###" uv_snapshot!(context.filters(), context.python_install().arg("--preview").arg("3.13t"), @r###"
success: true success: true
exit_code: 0 exit_code: 0
----- stdout ----- ----- stdout -----
@ -80,6 +204,30 @@ fn python_install_freethreaded() {
Searching for Python versions matching: Python 3.13t Searching for Python versions matching: Python 3.13t
Installed Python 3.13.0 in [TIME] Installed Python 3.13.0 in [TIME]
+ cpython-3.13.0+freethreaded-[PLATFORM] + cpython-3.13.0+freethreaded-[PLATFORM]
warning: `[TEMP_DIR]/bin` is not on your PATH. To use the installed Python executable, run `export PATH="[TEMP_DIR]/bin:$PATH"`.
"###);
let bin_python = context
.temp_dir
.child("bin")
.child(format!("python3.13t{}", std::env::consts::EXE_SUFFIX));
// The executable should be installed in the bin directory
bin_python.assert(predicate::path::exists());
// On Unix, it should be a link
#[cfg(unix)]
bin_python.assert(predicate::path::is_symlink());
// The executable should "work"
uv_snapshot!(context.filters(), Command::new(bin_python.as_os_str())
.arg("-c").arg("import subprocess; print('hello world')"), @r###"
success: true
exit_code: 0
----- stdout -----
hello world
----- stderr -----
"###); "###);
// Should be distinct from 3.13 // Should be distinct from 3.13

View file

@ -4302,11 +4302,11 @@ Download and install Python versions.
Multiple Python versions may be requested. Multiple Python versions may be requested.
Supports CPython and PyPy. Supports CPython and PyPy. CPython distributions are downloaded from the `python-build-standalone` project. PyPy distributions are downloaded from `python.org`.
CPython distributions are downloaded from the `python-build-standalone` project. Python versions are installed into the uv Python directory, which can be retrieved with `uv python dir`.
Python versions are installed into the uv Python directory, which can be retrieved with `uv python dir`. A `python` executable is not made globally available, managed Python versions are only used in uv commands or in active virtual environments. A `python` executable is not made globally available, managed Python versions are only used in uv commands or in active virtual environments. There is experimental support for adding Python executables to the `PATH` — use the `--preview` flag to enable this behavior.
See `uv help python` to view supported request formats. See `uv help python` to view supported request formats.
@ -4693,6 +4693,8 @@ By default, Python installations are stored in the uv data directory at `$XDG_DA
The Python installation directory may be overridden with `$UV_PYTHON_INSTALL_DIR`. The Python installation directory may be overridden with `$UV_PYTHON_INSTALL_DIR`.
To instead view the directory uv installs Python executables into, use the `--bin` flag.
<h3 class="cli-reference">Usage</h3> <h3 class="cli-reference">Usage</h3>
``` ```
@ -4701,7 +4703,25 @@ uv python dir [OPTIONS]
<h3 class="cli-reference">Options</h3> <h3 class="cli-reference">Options</h3>
<dl class="cli-reference"><dt><code>--cache-dir</code> <i>cache-dir</i></dt><dd><p>Path to the cache directory.</p> <dl class="cli-reference"><dt><code>--bin</code></dt><dd><p>Show the directory into which <code>uv python</code> will install Python executables.</p>
<p>Note this directory is only used when installing with preview mode enabled.</p>
<p>By default, <code>uv python dir</code> shows the directory into which the Python distributions themselves are installed, rather than the directory containing the linked executables.</p>
<p>The Python executable directory is determined according to the XDG standard and is derived from the following environment variables, in order of preference:</p>
<ul>
<li><code>$UV_PYTHON_BIN_DIR</code></li>
<li><code>$XDG_BIN_HOME</code></li>
<li><code>$XDG_DATA_HOME/../bin</code></li>
<li><code>$HOME/.local/bin</code></li>
</ul>
</dd><dt><code>--cache-dir</code> <i>cache-dir</i></dt><dd><p>Path to the cache directory.</p>
<p>Defaults to <code>$XDG_CACHE_HOME/uv</code> or <code>$HOME/.cache/uv</code> on macOS and Linux, and <code>%LOCALAPPDATA%\uv\cache</code> on Windows.</p> <p>Defaults to <code>$XDG_CACHE_HOME/uv</code> or <code>$HOME/.cache/uv</code> on macOS and Linux, and <code>%LOCALAPPDATA%\uv\cache</code> on Windows.</p>