Skip to content

Commit 4dd36b7

Browse files
authored
Install versioned Python executables into the bin directory during uv python install (#8458)
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
1 parent 94fc35e commit 4dd36b7

File tree

19 files changed

+594
-86
lines changed

19 files changed

+594
-86
lines changed

.github/workflows/ci.yml

+2-2
Original file line numberDiff line numberDiff line change
@@ -702,7 +702,7 @@ jobs:
702702
703703
- name: "Install free-threaded Python via uv"
704704
run: |
705-
./uv python install 3.13t
705+
./uv python install -v 3.13t
706706
./uv venv -p 3.13t --python-preference only-managed
707707
708708
- name: "Check version"
@@ -774,7 +774,7 @@ jobs:
774774
run: chmod +x ./uv
775775

776776
- name: "Install PyPy"
777-
run: ./uv python install pypy3.9
777+
run: ./uv python install -v pypy3.9
778778

779779
- name: "Create a virtual environment"
780780
run: |

Cargo.lock

+2
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/uv-cli/src/lib.rs

+31-7
Original file line numberDiff line numberDiff line change
@@ -3803,14 +3803,15 @@ pub enum PythonCommand {
38033803
///
38043804
/// Multiple Python versions may be requested.
38053805
///
3806-
/// Supports CPython and PyPy.
3806+
/// Supports CPython and PyPy. CPython distributions are downloaded from the
3807+
/// `python-build-standalone` project. PyPy distributions are downloaded from `python.org`.
38073808
///
3808-
/// CPython distributions are downloaded from the `python-build-standalone` project.
3809+
/// Python versions are installed into the uv Python directory, which can be retrieved with `uv
3810+
/// python dir`.
38093811
///
3810-
/// Python versions are installed into the uv Python directory, which can be
3811-
/// retrieved with `uv python dir`. A `python` executable is not made
3812-
/// globally available, managed Python versions are only used in uv
3813-
/// commands or in active virtual environments.
3812+
/// A `python` executable is not made globally available, managed Python versions are only used
3813+
/// in uv commands or in active virtual environments. There is experimental support for
3814+
/// adding Python executables to the `PATH` — use the `--preview` flag to enable this behavior.
38143815
///
38153816
/// See `uv help python` to view supported request formats.
38163817
Install(PythonInstallArgs),
@@ -3838,7 +3839,9 @@ pub enum PythonCommand {
38383839
/// `%APPDATA%\uv\data\python` on Windows.
38393840
///
38403841
/// The Python installation directory may be overridden with `$UV_PYTHON_INSTALL_DIR`.
3841-
Dir,
3842+
///
3843+
/// To instead view the directory uv installs Python executables into, use the `--bin` flag.
3844+
Dir(PythonDirArgs),
38423845

38433846
/// Uninstall Python versions.
38443847
Uninstall(PythonUninstallArgs),
@@ -3866,6 +3869,27 @@ pub struct PythonListArgs {
38663869
pub only_installed: bool,
38673870
}
38683871

3872+
#[derive(Args)]
3873+
#[allow(clippy::struct_excessive_bools)]
3874+
pub struct PythonDirArgs {
3875+
/// Show the directory into which `uv python` will install Python executables.
3876+
///
3877+
/// Note this directory is only used when installing with preview mode enabled.
3878+
///
3879+
/// By default, `uv python dir` shows the directory into which the Python distributions
3880+
/// themselves are installed, rather than the directory containing the linked executables.
3881+
///
3882+
/// The Python executable directory is determined according to the XDG standard and is derived
3883+
/// from the following environment variables, in order of preference:
3884+
///
3885+
/// - `$UV_PYTHON_BIN_DIR`
3886+
/// - `$XDG_BIN_HOME`
3887+
/// - `$XDG_DATA_HOME/../bin`
3888+
/// - `$HOME/.local/bin`
3889+
#[arg(long, verbatim_doc_comment)]
3890+
pub bin: bool,
3891+
}
3892+
38693893
#[derive(Args)]
38703894
#[allow(clippy::struct_excessive_bools)]
38713895
pub struct PythonInstallArgs {

crates/uv-python/Cargo.toml

+1
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ uv-cache = { workspace = true }
2020
uv-cache-info = { workspace = true }
2121
uv-cache-key = { workspace = true }
2222
uv-client = { workspace = true }
23+
uv-dirs = { workspace = true }
2324
uv-distribution-filename = { workspace = true }
2425
uv-extract = { workspace = true }
2526
uv-fs = { workspace = true }

crates/uv-python/src/discovery.rs

+11-6
Original file line numberDiff line numberDiff line change
@@ -1236,6 +1236,16 @@ impl PythonVariant {
12361236
PythonVariant::Freethreaded => interpreter.gil_disabled(),
12371237
}
12381238
}
1239+
1240+
/// Return the lib or executable suffix for the variant, e.g., `t` for `python3.13t`.
1241+
///
1242+
/// Returns an empty string for the default Python variant.
1243+
pub fn suffix(self) -> &'static str {
1244+
match self {
1245+
Self::Default => "",
1246+
Self::Freethreaded => "t",
1247+
}
1248+
}
12391249
}
12401250
impl PythonRequest {
12411251
/// Create a request from a string.
@@ -1635,12 +1645,7 @@ impl std::fmt::Display for ExecutableName {
16351645
if let Some(prerelease) = &self.prerelease {
16361646
write!(f, "{prerelease}")?;
16371647
}
1638-
match self.variant {
1639-
PythonVariant::Default => {}
1640-
PythonVariant::Freethreaded => {
1641-
f.write_str("t")?;
1642-
}
1643-
};
1648+
f.write_str(self.variant.suffix())?;
16441649
f.write_str(std::env::consts::EXE_SUFFIX)?;
16451650
Ok(())
16461651
}

crates/uv-python/src/installation.rs

+11
Original file line numberDiff line numberDiff line change
@@ -305,6 +305,17 @@ impl PythonInstallationKey {
305305
pub fn libc(&self) -> &Libc {
306306
&self.libc
307307
}
308+
309+
/// Return a canonical name for a versioned executable.
310+
pub fn versioned_executable_name(&self) -> String {
311+
format!(
312+
"python{maj}.{min}{var}{exe}",
313+
maj = self.major,
314+
min = self.minor,
315+
var = self.variant.suffix(),
316+
exe = std::env::consts::EXE_SUFFIX
317+
)
318+
}
308319
}
309320

310321
impl fmt::Display for PythonInstallationKey {

crates/uv-python/src/managed.rs

+141-37
Original file line numberDiff line numberDiff line change
@@ -53,14 +53,31 @@ pub enum Error {
5353
#[source]
5454
err: io::Error,
5555
},
56+
#[error("Failed to create Python executable link at {} from {}", to.user_display(), from.user_display())]
57+
LinkExecutable {
58+
from: PathBuf,
59+
to: PathBuf,
60+
#[source]
61+
err: io::Error,
62+
},
63+
#[error("Failed to create directory for Python executable link at {}", to.user_display())]
64+
ExecutableDirectory {
65+
to: PathBuf,
66+
#[source]
67+
err: io::Error,
68+
},
5669
#[error("Failed to read Python installation directory: {0}", dir.user_display())]
5770
ReadError {
5871
dir: PathBuf,
5972
#[source]
6073
err: io::Error,
6174
},
75+
#[error("Failed to find a directory to install executables into")]
76+
NoExecutableDirectory,
6277
#[error("Failed to read managed Python directory name: {0}")]
6378
NameError(String),
79+
#[error("Failed to construct absolute path to managed Python directory: {}", _0.user_display())]
80+
AbsolutePath(PathBuf, #[source] std::io::Error),
6481
#[error(transparent)]
6582
NameParseError(#[from] installation::PythonInstallationKeyError),
6683
#[error(transparent)]
@@ -267,18 +284,78 @@ impl ManagedPythonInstallation {
267284
.ok_or(Error::NameError("not a valid string".to_string()))?,
268285
)?;
269286

287+
let path = std::path::absolute(&path).map_err(|err| Error::AbsolutePath(path, err))?;
288+
270289
Ok(Self { path, key })
271290
}
272291

273-
/// The path to this toolchain's Python executable.
292+
/// The path to this managed installation's Python executable.
293+
///
294+
/// If the installation has multiple execututables i.e., `python`, `python3`, etc., this will
295+
/// return the _canonical_ executable name which the other names link to. On Unix, this is
296+
/// `python{major}.{minor}{variant}` and on Windows, this is `python{exe}`.
274297
pub fn executable(&self) -> PathBuf {
275-
if cfg!(windows) {
276-
self.python_dir().join("python.exe")
298+
let implementation = match self.implementation() {
299+
ImplementationName::CPython => "python",
300+
ImplementationName::PyPy => "pypy",
301+
ImplementationName::GraalPy => {
302+
unreachable!("Managed installations of GraalPy are not supported")
303+
}
304+
};
305+
306+
let version = match self.implementation() {
307+
ImplementationName::CPython => {
308+
if cfg!(unix) {
309+
format!("{}.{}", self.key.major, self.key.minor)
310+
} else {
311+
String::new()
312+
}
313+
}
314+
// PyPy uses a full version number, even on Windows.
315+
ImplementationName::PyPy => format!("{}.{}", self.key.major, self.key.minor),
316+
ImplementationName::GraalPy => {
317+
unreachable!("Managed installations of GraalPy are not supported")
318+
}
319+
};
320+
321+
// On Windows, the executable is just `python.exe` even for alternative variants
322+
let variant = if cfg!(unix) {
323+
self.key.variant.suffix()
324+
} else {
325+
""
326+
};
327+
328+
let name = format!(
329+
"{implementation}{version}{variant}{exe}",
330+
exe = std::env::consts::EXE_SUFFIX
331+
);
332+
333+
let executable = if cfg!(windows) {
334+
self.python_dir().join(name)
277335
} else if cfg!(unix) {
278-
self.python_dir().join("bin").join("python3")
336+
self.python_dir().join("bin").join(name)
279337
} else {
280338
unimplemented!("Only Windows and Unix systems are supported.")
339+
};
340+
341+
// Workaround for python-build-standalone v20241016 which is missing the standard
342+
// `python.exe` executable in free-threaded distributions on Windows.
343+
//
344+
// See https://github.com/astral-sh/uv/issues/8298
345+
if cfg!(windows)
346+
&& matches!(self.key.variant, PythonVariant::Freethreaded)
347+
&& !executable.exists()
348+
{
349+
// This is the alternative executable name for the freethreaded variant
350+
return self.python_dir().join(format!(
351+
"python{}.{}t{}",
352+
self.key.major,
353+
self.key.minor,
354+
std::env::consts::EXE_SUFFIX
355+
));
281356
}
357+
358+
executable
282359
}
283360

284361
fn python_dir(&self) -> PathBuf {
@@ -336,39 +413,38 @@ impl ManagedPythonInstallation {
336413
pub fn ensure_canonical_executables(&self) -> Result<(), Error> {
337414
let python = self.executable();
338415

339-
// Workaround for python-build-standalone v20241016 which is missing the standard
340-
// `python.exe` executable in free-threaded distributions on Windows.
341-
//
342-
// See https://github.com/astral-sh/uv/issues/8298
343-
if !python.try_exists()? {
344-
match self.key.variant {
345-
PythonVariant::Default => return Err(Error::MissingExecutable(python.clone())),
346-
PythonVariant::Freethreaded => {
347-
// This is the alternative executable name for the freethreaded variant
348-
let python_in_dist = self.python_dir().join(format!(
349-
"python{}.{}t{}",
350-
self.key.major,
351-
self.key.minor,
352-
std::env::consts::EXE_SUFFIX
353-
));
416+
let canonical_names = &["python"];
417+
418+
for name in canonical_names {
419+
let executable =
420+
python.with_file_name(format!("{name}{exe}", exe = std::env::consts::EXE_SUFFIX));
421+
422+
// Do not attempt to perform same-file copies — this is fine on Unix but fails on
423+
// Windows with a permission error instead of 'already exists'
424+
if executable == python {
425+
continue;
426+
}
427+
428+
match uv_fs::symlink_copy_fallback_file(&python, &executable) {
429+
Ok(()) => {
354430
debug!(
355-
"Creating link {} -> {}",
431+
"Created link {} -> {}",
432+
executable.user_display(),
356433
python.user_display(),
357-
python_in_dist.user_display()
358434
);
359-
uv_fs::symlink_copy_fallback_file(&python_in_dist, &python).map_err(|err| {
360-
if err.kind() == io::ErrorKind::NotFound {
361-
Error::MissingExecutable(python_in_dist.clone())
362-
} else {
363-
Error::CanonicalizeExecutable {
364-
from: python_in_dist,
365-
to: python,
366-
err,
367-
}
368-
}
369-
})?;
370435
}
371-
}
436+
Err(err) if err.kind() == io::ErrorKind::NotFound => {
437+
return Err(Error::MissingExecutable(python.clone()))
438+
}
439+
Err(err) if err.kind() == io::ErrorKind::AlreadyExists => {}
440+
Err(err) => {
441+
return Err(Error::CanonicalizeExecutable {
442+
from: executable,
443+
to: python,
444+
err,
445+
})
446+
}
447+
};
372448
}
373449

374450
Ok(())
@@ -381,10 +457,7 @@ impl ManagedPythonInstallation {
381457
let stdlib = if matches!(self.key.os, Os(target_lexicon::OperatingSystem::Windows)) {
382458
self.python_dir().join("Lib")
383459
} else {
384-
let lib_suffix = match self.key.variant {
385-
PythonVariant::Default => "",
386-
PythonVariant::Freethreaded => "t",
387-
};
460+
let lib_suffix = self.key.variant.suffix();
388461
let python = if matches!(
389462
self.key.implementation,
390463
LenientImplementationName::Known(ImplementationName::PyPy)
@@ -401,6 +474,31 @@ impl ManagedPythonInstallation {
401474

402475
Ok(())
403476
}
477+
478+
/// Create a link to the Python executable in the given `bin` directory.
479+
pub fn create_bin_link(&self, bin: &Path) -> Result<PathBuf, Error> {
480+
let python = self.executable();
481+
482+
fs_err::create_dir_all(bin).map_err(|err| Error::ExecutableDirectory {
483+
to: bin.to_path_buf(),
484+
err,
485+
})?;
486+
487+
// TODO(zanieb): Add support for a "default" which
488+
let python_in_bin = bin.join(self.key.versioned_executable_name());
489+
490+
match uv_fs::symlink_copy_fallback_file(&python, &python_in_bin) {
491+
Ok(()) => Ok(python_in_bin),
492+
Err(err) if err.kind() == io::ErrorKind::NotFound => {
493+
Err(Error::MissingExecutable(python.clone()))
494+
}
495+
Err(err) => Err(Error::LinkExecutable {
496+
from: python,
497+
to: python_in_bin,
498+
err,
499+
}),
500+
}
501+
}
404502
}
405503

406504
/// Generate a platform portion of a key from the environment.
@@ -423,3 +521,9 @@ impl fmt::Display for ManagedPythonInstallation {
423521
)
424522
}
425523
}
524+
525+
/// Find the directory to install Python executables into.
526+
pub fn python_executable_dir() -> Result<PathBuf, Error> {
527+
uv_dirs::user_executable_directory(Some(EnvVars::UV_PYTHON_BIN_DIR))
528+
.ok_or(Error::NoExecutableDirectory)
529+
}

crates/uv-static/src/env_vars.rs

+3
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,9 @@ impl EnvVars {
141141
/// Specifies the path to the project virtual environment.
142142
pub const UV_PROJECT_ENVIRONMENT: &'static str = "UV_PROJECT_ENVIRONMENT";
143143

144+
/// Specifies the directory to place links to installed, managed Python executables.
145+
pub const UV_PYTHON_BIN_DIR: &'static str = "UV_PYTHON_BIN_DIR";
146+
144147
/// Specifies the directory for storing managed Python installations.
145148
pub const UV_PYTHON_INSTALL_DIR: &'static str = "UV_PYTHON_INSTALL_DIR";
146149

0 commit comments

Comments
 (0)